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,10 @@
import { widgetQuickSettingBar } from './quick-setting-bar/index.js';
import { createWidgetTools, toolsWidgetPresets } from './tools/index.js';
import { widgetViewsBar } from './views-bar/index.js';
export const widgetPresets = {
viewBar: widgetViewsBar,
quickSettingBar: widgetQuickSettingBar,
createTools: createWidgetTools,
tools: toolsWidgetPresets,
};
@@ -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();
},
}),
],
},
});
};
@@ -0,0 +1,33 @@
import { createUniComponentFromWebComponent } from '../../core/index.js';
import { uniMap } from '../../core/utils/uni-component/operation.js';
import type {
DataViewWidget,
DataViewWidgetProps,
} from '../../core/widget/types.js';
import { DataViewHeaderToolsFilter } from './presets/filter/filter.js';
import { DataViewHeaderToolsSearch } from './presets/search/search.js';
import { DataViewHeaderToolsSort } from './presets/sort/sort.js';
import { DataViewHeaderToolsAddRow } from './presets/table-add-row/add-row.js';
import { DataViewHeaderToolsViewOptions } from './presets/view-options/view-options.js';
import { DataViewHeaderTools } from './tools-view.js';
export const toolsWidgetPresets = {
sort: createUniComponentFromWebComponent(DataViewHeaderToolsSort),
filter: createUniComponentFromWebComponent(DataViewHeaderToolsFilter),
search: createUniComponentFromWebComponent(DataViewHeaderToolsSearch),
viewOptions: createUniComponentFromWebComponent(
DataViewHeaderToolsViewOptions
),
tableAddRow: createUniComponentFromWebComponent(DataViewHeaderToolsAddRow),
};
export const createWidgetTools = (
toolsMap: Record<string, DataViewWidget[]>
) => {
return uniMap(
createUniComponentFromWebComponent(DataViewHeaderTools),
(props: DataViewWidgetProps) => ({
...props,
toolsMap,
})
);
};
@@ -0,0 +1,115 @@
import { popupTargetFromElement } from '@blocksuite/affine-components/context-menu';
import { IS_MOBILE } from '@blocksuite/global/env';
import { FilterIcon } from '@blocksuite/icons/lit';
import { computed } from '@preact/signals-core';
import { cssVarV2 } from '@toeverything/theme/v2';
import { css, html, nothing } from 'lit';
import { styleMap } from 'lit/directives/style-map.js';
import { popCreateFilter } from '../../../../core/filter/add-filter.js';
import { filterTraitKey } from '../../../../core/filter/trait.js';
import type { FilterGroup } from '../../../../core/filter/types.js';
import { emptyFilterGroup } from '../../../../core/filter/utils.js';
import { WidgetBase } from '../../../../core/widget/widget-base.js';
import { ShowQuickSettingBarContextKey } from '../../../quick-setting-bar/context.js';
const styles = css`
.affine-database-filter-button {
display: flex;
align-items: center;
gap: 6px;
line-height: 20px;
padding: 2px;
border-radius: 4px;
cursor: pointer;
font-size: 20px;
}
.affine-database-filter-button:hover,
.affine-database-filter-button.active {
background-color: var(--affine-hover-color);
}
.affine-database-filter-button {
}
`;
export class DataViewHeaderToolsFilter extends WidgetBase {
static override styles = styles;
hasFilter = computed(() => {
return this.filterTrait?.hasFilter$.value ?? false;
});
private get _filter(): FilterGroup {
return this.filterTrait?.filter$.value ?? emptyFilterGroup;
}
private set _filter(filter: FilterGroup) {
this.filterTrait?.filterSet(filter);
}
get filterTrait() {
return this.view.traitGet(filterTraitKey);
}
private get readonly() {
return this.view.readonly$.value;
}
private clickFilter(event: MouseEvent) {
if (this.hasFilter.value) {
this.toggleShowFilter();
return;
}
popCreateFilter(
popupTargetFromElement(event.currentTarget as HTMLElement),
{
vars: this.view.vars$,
onSelect: filter => {
this._filter = {
...this._filter,
conditions: [filter],
};
this.toggleShowFilter(true);
},
}
);
return;
}
override connectedCallback() {
super.connectedCallback();
this.style.display = IS_MOBILE ? 'none' : 'flex';
}
override render() {
if (this.readonly) return nothing;
const style = styleMap({
color: this.hasFilter.value
? cssVarV2('text/emphasis')
: cssVarV2('icon/primary'),
});
return html` <div
@click="${this.clickFilter}"
style="${style}"
class="affine-database-filter-button"
>
${FilterIcon()}
</div>`;
}
toggleShowFilter(show?: boolean) {
const map = this.view.contextGet(ShowQuickSettingBarContextKey);
map.value = {
...map.value,
[this.view.id]: show ?? !map.value[this.view.id],
};
}
}
declare global {
interface HTMLElementTagNameMap {
'data-view-header-tools-filter': DataViewHeaderToolsFilter;
}
}
@@ -0,0 +1,191 @@
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { IS_MOBILE } from '@blocksuite/global/env';
import { CloseIcon, SearchIcon } from '@blocksuite/icons/lit';
import { baseTheme } from '@toeverything/theme';
import { css, html, unsafeCSS } from 'lit';
import { query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { styleMap } from 'lit/directives/style-map.js';
import { stopPropagation } from '../../../../core/utils/event.js';
import { WidgetBase } from '../../../../core/widget/widget-base.js';
import type {
KanbanSingleView,
TableSingleView,
} from '../../../../view-presets/index.js';
const styles = css`
.affine-database-search-container {
position: relative;
display: flex;
align-items: center;
gap: 8px;
width: 24px;
height: 24px;
border-radius: 4px;
transition: width 0.3s ease;
overflow: hidden;
}
.search-container-expand {
overflow: visible;
width: 138px;
background-color: var(--affine-hover-color);
}
.search-input-container {
display: flex;
align-items: center;
}
.close-icon {
display: flex;
align-items: center;
padding-right: 8px;
height: 100%;
cursor: pointer;
}
.affine-database-search-input-icon {
position: absolute;
left: 0;
font-size: 20px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
cursor: pointer;
padding: 2px;
border-radius: 4px;
color: ${unsafeCSSVarV2('icon/primary')};
}
.affine-database-search-input-icon:hover {
background: var(--affine-hover-color);
}
.search-container-expand .affine-database-search-input-icon {
left: 4px;
pointer-events: none;
}
.affine-database-search-input {
flex: 1;
width: 100%;
padding: 0 2px 0 30px;
border: none;
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
font-size: var(--affine-font-sm);
box-sizing: border-box;
color: inherit;
background: transparent;
outline: none;
}
.affine-database-search-input::placeholder {
color: var(--affine-placeholder-color);
font-size: var(--affine-font-sm);
}
`;
export class DataViewHeaderToolsSearch extends WidgetBase<
TableSingleView | KanbanSingleView
> {
static override styles = styles;
private _clearSearch = () => {
this._searchInput.value = '';
this.view.setSearch('');
this.preventBlur = true;
setTimeout(() => {
this.preventBlur = false;
});
};
private _clickSearch = (e: MouseEvent) => {
e.stopPropagation();
this.showSearch = true;
};
private _onSearch = (event: InputEvent) => {
const el = event.target as HTMLInputElement;
const inputValue = el.value.trim();
this.view.setSearch(inputValue);
};
private _onSearchBlur = () => {
if (this._searchInput.value || this.preventBlur) {
return;
}
this.showSearch = false;
};
private _onSearchKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
if (this._searchInput.value) {
this._searchInput.value = '';
this.view.setSearch('');
} else {
this.showSearch = false;
}
}
};
preventBlur = false;
override connectedCallback() {
super.connectedCallback();
this.style.display = IS_MOBILE ? 'none' : 'flex';
}
override render() {
const searchToolClassMap = classMap({
'affine-database-search-container': true,
'search-container-expand': this.showSearch,
active: this.showSearch,
});
return html`
<label class="${searchToolClassMap}" @click="${this._clickSearch}">
<div class="affine-database-search-input-icon">${SearchIcon()}</div>
<input
placeholder="Search..."
class="affine-database-search-input"
@input="${this._onSearch}"
@click="${(event: MouseEvent) => event.stopPropagation()}"
@keydown="${this._onSearchKeydown}"
@pointerdown="${stopPropagation}"
@blur="${this._onSearchBlur}"
/>
<div class="close-icon" @mousedown="${this._clearSearch}">
${CloseIcon()}
<affine-tooltip>
<span
style=${styleMap({
display: 'flex',
alignItems: 'center',
boxSizing: 'border-box',
padding: '2px 6px',
borderRadius: '4px',
background: 'var(--affine-white-10)',
})}
>Esc</span
>
to clear all
</affine-tooltip>
</div>
</label>
`;
}
@query('.affine-database-search-input')
private accessor _searchInput!: HTMLInputElement;
@state()
private accessor showSearch = false;
}
declare global {
interface HTMLElementTagNameMap {
'data-view-header-tools-search': DataViewHeaderToolsSearch;
}
}
@@ -0,0 +1,121 @@
import { popupTargetFromElement } from '@blocksuite/affine-components/context-menu';
import { IS_MOBILE } from '@blocksuite/global/env';
import { SortIcon } from '@blocksuite/icons/lit';
import { computed } from '@preact/signals-core';
import { cssVarV2 } from '@toeverything/theme/v2';
import { css, html, nothing } from 'lit';
import { styleMap } from 'lit/directives/style-map.js';
import { popCreateSort } from '../../../../core/sort/add-sort.js';
import { sortTraitKey } from '../../../../core/sort/manager.js';
import { createSortUtils } from '../../../../core/sort/utils.js';
import { WidgetBase } from '../../../../core/widget/widget-base.js';
import { ShowQuickSettingBarContextKey } from '../../../quick-setting-bar/context.js';
import { popSortRoot } from '../../../quick-setting-bar/sort/root-panel.js';
const styles = css`
.affine-database-sort-button {
display: flex;
align-items: center;
gap: 6px;
line-height: 20px;
padding: 2px;
border-radius: 4px;
cursor: pointer;
font-size: 20px;
}
.affine-database-sort-button:hover,
.affine-database-sort-button.active {
background-color: var(--affine-hover-color);
}
.affine-database-sort-button {
}
`;
export class DataViewHeaderToolsSort extends WidgetBase {
static override styles = styles;
sortUtils$ = computed(() => {
const sortTrait = this.view.traitGet(sortTraitKey);
if (sortTrait) {
return createSortUtils(sortTrait, this.dataViewInstance.eventTrace);
}
return;
});
hasSort = computed(() => {
return (this.sortUtils$.value?.sortList$?.value?.length ?? 0) > 0;
});
private get readonly() {
return this.view.readonly$.value;
}
private clickSort(event: MouseEvent) {
const sortUtils = this.sortUtils$.value;
if (!sortUtils) {
return;
}
if (this.hasSort.value) {
this.toggleShowQuickSettingBar();
return;
}
popCreateSort(popupTargetFromElement(event.currentTarget as HTMLElement), {
sortUtils: {
...sortUtils,
add: sort => {
sortUtils.add(sort);
this.toggleShowQuickSettingBar(true);
requestAnimationFrame(() => {
const ele = this.closest(
'affine-data-view-renderer'
)?.querySelector('.data-view-sort-button');
if (ele) {
popSortRoot(popupTargetFromElement(ele as HTMLElement), {
sortUtils: sortUtils,
});
}
});
},
},
});
return;
}
override connectedCallback() {
super.connectedCallback();
this.style.display = IS_MOBILE ? 'none' : 'flex';
}
override render() {
if (this.readonly) return nothing;
const style = styleMap({
color: this.hasSort.value
? cssVarV2('text/emphasis')
: cssVarV2('icon/primary'),
});
return html` <div
@click="${this.clickSort}"
style="${style}"
class="affine-database-sort-button"
>
${SortIcon()}
</div>`;
}
toggleShowQuickSettingBar(show?: boolean) {
const map = this.view.contextGet(ShowQuickSettingBarContextKey);
map.value = {
...map.value,
[this.view.id]: show ?? !map.value[this.view.id],
};
}
}
declare global {
interface HTMLElementTagNameMap {
'data-view-header-tools-sort': DataViewHeaderToolsSort;
}
}
@@ -0,0 +1,51 @@
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { IS_MOBILE } from '@blocksuite/global/env';
import { PlusIcon } from '@blocksuite/icons/lit';
import { css, html } from 'lit';
import { WidgetBase } from '../../../../core/widget/widget-base.js';
const styles = css`
.new-record {
font-weight: 500;
}
.new-record svg {
font-size: 20px;
color: ${unsafeCSSVarV2('icon/primary')};
}
`;
export class DataViewHeaderToolsAddRow extends WidgetBase {
static override styles = styles;
private _onAddNewRecord = () => {
if (this.readonly) return;
this.viewMethods.addRow?.('start');
};
private get readonly() {
return this.view.readonly$.value;
}
override render() {
if (this.readonly) {
return;
}
return html` <data-view-component-button
class="affine-database-toolbar-item new-record"
.onClick="${this._onAddNewRecord}"
.icon="${PlusIcon()}"
.text="${IS_MOBILE
? html`<span style="font-weight: 500">New</span>`
: html`<span style="font-weight: 500">New Record</span>`}"
>
</data-view-component-button>`;
}
}
declare global {
interface HTMLElementTagNameMap {
'data-view-header-tools-add-row': DataViewHeaderToolsAddRow;
}
}
@@ -0,0 +1,43 @@
import { ShadowlessElement } from '@blocksuite/block-std';
import { PlusIcon } from '@blocksuite/icons/lit';
import { html } from 'lit';
export class NewRecordPreview extends ShadowlessElement {
override render() {
return html`
<style>
affine-database-new-record-preview {
display: flex;
align-items: center;
justify-content: center;
position: fixed;
top: 0;
left: 0;
height: 32px;
width: 32px;
border: 1px solid var(--affine-border-color);
border-radius: 50%;
background: var(--affine-blue-100);
box-shadow:
0px 0px 10px rgba(0, 0, 0, 0.05),
0px 0px 0px 0.5px var(--affine-black-10);
cursor: none;
user-select: none;
pointer-events: none;
caret-color: transparent;
z-index: 99999;
}
affine-database-new-record-preview svg {
width: 16px;
height: 16px;
}
affine-database-new-record-preview path {
fill: var(--affine-brand-color);
}
</style>
${PlusIcon()}
`;
}
}
@@ -0,0 +1,379 @@
import {
menu,
type MenuButtonData,
type MenuConfig,
popMenu,
type PopupTarget,
popupTargetFromElement,
} from '@blocksuite/affine-components/context-menu';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import {
ArrowRightSmallIcon,
DeleteIcon,
DuplicateIcon,
FilterIcon,
GroupingIcon,
InfoIcon,
LayoutIcon,
MoreHorizontalIcon,
SortIcon,
} from '@blocksuite/icons/lit';
import { css, html } from 'lit';
import { styleMap } from 'lit/directives/style-map.js';
import { popPropertiesSetting } from '../../../../core/common/properties.js';
import { filterTraitKey } from '../../../../core/filter/trait.js';
import {
popGroupSetting,
popSelectGroupByProperty,
} from '../../../../core/group-by/setting.js';
import { groupTraitKey } from '../../../../core/group-by/trait.js';
import {
type DataViewInstance,
emptyFilterGroup,
popCreateFilter,
renderUniLit,
} from '../../../../core/index.js';
import { popCreateSort } from '../../../../core/sort/add-sort.js';
import { sortTraitKey } from '../../../../core/sort/manager.js';
import { createSortUtils } from '../../../../core/sort/utils.js';
import { WidgetBase } from '../../../../core/widget/widget-base.js';
import { popFilterRoot } from '../../../quick-setting-bar/filter/root-panel-view.js';
import { popSortRoot } from '../../../quick-setting-bar/sort/root-panel.js';
const styles = css`
.affine-database-toolbar-item.more-action {
padding: 2px;
border-radius: 4px;
display: flex;
align-items: center;
cursor: pointer;
}
.affine-database-toolbar-item.more-action:hover {
background: var(--affine-hover-color);
}
.affine-database-toolbar-item.more-action {
font-size: 20px;
color: ${unsafeCSSVarV2('icon/primary')};
}
.more-action.active {
background: var(--affine-hover-color);
}
`;
export class DataViewHeaderToolsViewOptions extends WidgetBase {
static override styles = styles;
clickMoreAction = (e: MouseEvent) => {
e.stopPropagation();
this.openMoreAction(popupTargetFromElement(e.currentTarget as HTMLElement));
};
openMoreAction = (target: PopupTarget) => {
popViewOptions(target, this.dataViewInstance);
};
override render() {
if (this.view.readonly$.value) {
return;
}
return html` <div
class="affine-database-toolbar-item more-action"
@click="${this.clickMoreAction}"
>
${MoreHorizontalIcon()}
</div>`;
}
}
declare global {
interface HTMLElementTagNameMap {
'data-view-header-tools-view-options': DataViewHeaderToolsViewOptions;
}
}
const createSettingMenus = (
target: PopupTarget,
dataViewInstance: DataViewInstance,
reopen: () => void
) => {
const view = dataViewInstance.view;
const settingItems: MenuConfig[] = [];
settingItems.push(
menu.action({
name: 'Properties',
prefix: InfoIcon(),
postfix: html` <div style="font-size: 14px;">
${view.properties$.value.length} shown
</div>
${ArrowRightSmallIcon()}`,
select: () => {
popPropertiesSetting(target, {
view: view,
onBack: reopen,
});
},
})
);
const filterTrait = view.traitGet(filterTraitKey);
if (filterTrait) {
const filterCount = filterTrait.filter$.value.conditions.length;
settingItems.push(
menu.action({
name: 'Filter',
prefix: FilterIcon(),
postfix: html` <div style="font-size: 14px;">
${filterCount === 0
? ''
: filterCount === 1
? '1 filter'
: `${filterCount} filters`}
</div>
${ArrowRightSmallIcon()}`,
select: () => {
if (!filterTrait.filter$.value.conditions.length) {
popCreateFilter(target, {
vars: view.vars$,
onBack: reopen,
onSelect: filter => {
filterTrait.filterSet({
...(filterTrait.filter$.value ?? emptyFilterGroup),
conditions: [...filterTrait.filter$.value.conditions, filter],
});
popFilterRoot(target, {
filterTrait: filterTrait,
onBack: reopen,
});
},
});
} else {
popFilterRoot(target, {
filterTrait: filterTrait,
onBack: reopen,
});
}
},
})
);
}
const sortTrait = view.traitGet(sortTraitKey);
if (sortTrait) {
const sortCount = sortTrait.sortList$.value.length;
settingItems.push(
menu.action({
name: 'Sort',
prefix: SortIcon(),
postfix: html` <div style="font-size: 14px;">
${sortCount === 0
? ''
: sortCount === 1
? '1 sort'
: `${sortCount} sorts`}
</div>
${ArrowRightSmallIcon()}`,
select: () => {
const sortList = sortTrait.sortList$.value;
const sortUtils = createSortUtils(
sortTrait,
dataViewInstance.eventTrace
);
if (!sortList.length) {
popCreateSort(target, {
sortUtils: sortUtils,
onBack: reopen,
});
} else {
popSortRoot(target, {
sortUtils: sortUtils,
title: {
text: 'Sort',
onBack: reopen,
},
});
}
},
})
);
}
const groupTrait = view.traitGet(groupTraitKey);
if (groupTrait) {
settingItems.push(
menu.action({
name: 'Group',
prefix: GroupingIcon(),
postfix: html` <div style="font-size: 14px;">
${groupTrait.property$.value?.name$.value ?? ''}
</div>
${ArrowRightSmallIcon()}`,
select: () => {
const groupBy = groupTrait.property$.value;
if (!groupBy) {
popSelectGroupByProperty(target, groupTrait, {
onSelect: () => popGroupSetting(target, groupTrait, reopen),
onBack: reopen,
});
} else {
popGroupSetting(target, groupTrait, reopen);
}
},
})
);
}
return settingItems;
};
export const popViewOptions = (
target: PopupTarget,
dataViewInstance: DataViewInstance,
onClose?: () => void
) => {
const view = dataViewInstance.view;
const reopen = () => {
popViewOptions(target, dataViewInstance);
};
const items: MenuConfig[] = [];
items.push(
menu.input({
initialValue: view.name$.value,
onChange: text => {
view.nameSet(text);
},
})
);
items.push(
menu.group({
items: [
menu.action({
name: 'Layout',
postfix: html` <div
style="font-size: 14px;text-transform: capitalize;"
>
${view.type}
</div>
${ArrowRightSmallIcon()}`,
select: () => {
const viewTypes = view.manager.viewMetas.map<MenuConfig>(meta => {
return menu => {
if (!menu.search(meta.model.defaultName)) {
return;
}
const isSelected =
meta.type === view.manager.currentView$.value.type;
const iconStyle = styleMap({
fontSize: '24px',
color: isSelected
? 'var(--affine-text-emphasis-color)'
: 'var(--affine-icon-secondary)',
});
const textStyle = styleMap({
fontSize: '14px',
lineHeight: '22px',
color: isSelected
? 'var(--affine-text-emphasis-color)'
: 'var(--affine-text-secondary-color)',
});
const data: MenuButtonData = {
content: () => html`
<div
style="color:var(--affine-text-emphasis-color);width:100%;display: flex;flex-direction: column;align-items: center;justify-content: center;padding: 6px 16px;"
>
<div style="${iconStyle}">
${renderUniLit(meta.renderer.icon)}
</div>
<div style="${textStyle}">${meta.model.defaultName}</div>
</div>
`,
select: () => {
view.manager.viewChangeType(
view.manager.currentViewId$.value,
meta.type
);
dataViewInstance.clearSelection();
},
class: {},
};
const containerStyle = styleMap({
flex: '1',
});
return html` <affine-menu-button
style="${containerStyle}"
.data="${data}"
.menu="${menu}"
></affine-menu-button>`;
};
});
popMenu(target, {
options: {
title: {
onBack: reopen,
text: 'Layout',
},
items: [
menu => {
const result = menu.renderItems(viewTypes);
if (result.length) {
return html` <div style="display: flex">${result}</div>`;
}
return html``;
},
// menu.toggleSwitch({
// name: 'Show block icon',
// on: true,
// onChange: value => {
// console.log(value);
// },
// }),
// menu.toggleSwitch({
// name: 'Show Vertical lines',
// on: true,
// onChange: value => {
// console.log(value);
// },
// }),
],
},
});
},
prefix: LayoutIcon(),
}),
],
})
);
items.push(
menu.group({
items: createSettingMenus(target, dataViewInstance, reopen),
})
);
items.push(
menu.group({
items: [
menu.action({
name: 'Duplicate',
prefix: DuplicateIcon(),
select: () => {
view.duplicate();
},
}),
menu.action({
name: 'Delete',
prefix: DeleteIcon(),
select: () => {
view.delete();
},
class: { 'delete-item': true },
}),
],
})
);
popMenu(target, {
options: {
title: {
text: 'View settings',
},
items,
onClose: onClose,
},
});
};
@@ -0,0 +1,83 @@
import { IS_MOBILE } from '@blocksuite/global/env';
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 DataViewInstance, renderUniLit } from '../../core/index.js';
import type { SingleView } from '../../core/view-manager/single-view.js';
import type { ViewManager } from '../../core/view-manager/view-manager.js';
import type { DataViewWidget } from '../../core/widget/types.js';
import { WidgetBase } from '../../core/widget/widget-base.js';
const styles = css`
.affine-database-toolbar {
display: flex;
align-items: center;
gap: 6px;
opacity: 0;
transition: opacity 150ms cubic-bezier(0.42, 0, 1, 1);
}
.toolbar-hover-container:hover .affine-database-toolbar {
visibility: visible;
opacity: 1;
}
.toolbar-hover-container:has(.active) .affine-database-toolbar {
visibility: visible;
opacity: 1;
}
.show-toolbar {
visibility: visible;
opacity: 1;
}
@media print {
.affine-database-toolbar {
display: none;
}
}
`;
export class DataViewHeaderTools extends WidgetBase {
static override styles = styles;
override render() {
const classList = classMap({
'show-toolbar': IS_MOBILE,
'affine-database-toolbar': true,
});
const tools = this.toolsMap[this.view.type];
return html` <div class="${classList}">
${repeat(tools ?? [], uni => {
return renderUniLit(uni, {
dataViewInstance: this.dataViewInstance,
});
})}
</div>`;
}
@state()
accessor showToolBar = false;
@property({ attribute: false })
accessor toolsMap!: Record<string, DataViewWidget[]>;
}
declare global {
interface HTMLElementTagNameMap {
'data-view-header-tools': DataViewHeaderTools;
}
}
export const renderTools = (
view: SingleView,
viewMethods: DataViewInstance,
viewSource: ViewManager
) => {
return html` <data-view-header-tools
.viewMethods="${viewMethods}"
.view="${view}"
.viewSource="${viewSource}"
></data-view-header-tools>`;
};
@@ -0,0 +1,11 @@
import {
createUniComponentFromWebComponent,
type DataViewWidgetProps,
} from '../../core/index.js';
import { DataViewHeaderViews } from './views-view.js';
export const widgetViewsBar = createUniComponentFromWebComponent<
DataViewWidgetProps & {
onChangeView?: (viewId: string) => void;
}
>(DataViewHeaderViews);
@@ -0,0 +1,305 @@
import {
menu,
popFilterableSimpleMenu,
popMenu,
type PopupTarget,
popupTargetFromElement,
} from '@blocksuite/affine-components/context-menu';
import {
DeleteIcon,
DuplicateIcon,
InfoIcon,
MoreHorizontalIcon,
MoveLeftIcon,
MoveRightIcon,
PlusIcon,
} from '@blocksuite/icons/lit';
import { css, html } from 'lit';
import { property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { WidgetBase } from '../../core/widget/widget-base.js';
export class DataViewHeaderViews extends WidgetBase {
static override styles = css`
data-view-header-views {
height: 28px;
display: flex;
user-select: none;
gap: 4px;
}
data-view-header-views::-webkit-scrollbar-thumb {
width: 1px;
}
.database-view-button {
height: 100%;
cursor: pointer;
padding: 2px 4px;
border-radius: 4px;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
color: var(--affine-text-secondary-color);
white-space: nowrap;
max-width: 200px;
min-width: 28px;
}
.database-view-button .name {
align-items: center;
font-size: 15px;
line-height: 24px;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
padding-right: 2px;
}
.database-view-button .icon {
margin-right: 6px;
display: block;
flex-shrink: 0;
}
.database-view-button .icon svg {
width: 16px;
height: 16px;
}
.database-view-button.selected {
color: var(--affine-text-primary-color);
background-color: var(--affine-hover-color-filled);
}
`;
_addViewMenu = (event: MouseEvent) => {
popFilterableSimpleMenu(
popupTargetFromElement(event.currentTarget as HTMLElement),
this.dataSource.viewMetas.map(v => {
return menu.action({
name: v.model.defaultName,
prefix: html`<uni-lit .uni=${v.renderer.icon}></uni-lit>`,
select: () => {
const id = this.viewManager.viewAdd(v.type);
this.viewManager.setCurrentView(id);
},
});
})
);
};
_showMore = (event: MouseEvent) => {
const views = this.viewManager.views$.value;
popFilterableSimpleMenu(
popupTargetFromElement(event.currentTarget as HTMLElement),
[
menu.group({
items: views.map(id => {
const openViewOption = (event: MouseEvent) => {
event.stopPropagation();
this.openViewOption(
popupTargetFromElement(event.currentTarget as HTMLElement),
id
);
};
const view = this.viewManager.viewGet(id);
return menu.action({
prefix: html`<uni-lit
.uni=${this.getRenderer(id).icon}
></uni-lit>`,
name: view.name$.value ?? '',
label: () => html`${view.name$.value}`,
isSelected: this.viewManager.currentViewId$.value === id,
select: () => {
this.viewManager.setCurrentView(id);
},
postfix: html`<div
class="dv-hover dv-round-4"
@click="${openViewOption}"
style="display:flex;align-items:center;"
>
${MoreHorizontalIcon()}
</div>`,
});
}),
}),
menu.group({
items: this.dataSource.viewMetas.map(v => {
return menu.action({
name: `Create ${v.model.defaultName}`,
hide: () => this.readonly,
prefix: PlusIcon(),
select: () => {
const id = this.viewManager.viewAdd(v.type);
this.viewManager.setCurrentView(id);
},
});
}),
}),
]
);
};
openViewOption = (target: PopupTarget, id: string) => {
if (this.readonly) {
return;
}
const views = this.viewManager.views$.value;
const index = views.findIndex(v => v === id);
const view = this.viewManager.viewGet(views[index]);
if (!view) {
return;
}
popMenu(target, {
options: {
items: [
menu.input({
initialValue: view.name$.value,
onChange: text => {
view.nameSet(text);
},
}),
menu.group({
items: [
menu.action({
name: 'Edit View',
prefix: InfoIcon(),
select: () => {
this.closest('affine-data-view-renderer')
?.querySelector('data-view-header-tools-view-options')
?.openMoreAction(target);
},
}),
menu.action({
name: 'Move Left',
hide: () => index === 0,
prefix: MoveLeftIcon(),
select: () => {
const targetId = views[index - 1];
this.viewManager.moveTo(
id,
targetId ? { before: true, id: targetId } : 'start'
);
},
}),
menu.action({
name: 'Move Right',
prefix: MoveRightIcon(),
hide: () => index === views.length - 1,
select: () => {
const targetId = views[index + 1];
this.viewManager.moveTo(
id,
targetId ? { before: false, id: targetId } : 'end'
);
},
}),
],
}),
menu.group({
items: [
menu.action({
name: 'Duplicate',
prefix: DuplicateIcon(),
select: () => {
this.viewManager.viewDuplicate(id);
},
}),
menu.action({
name: 'Delete',
prefix: DeleteIcon(),
select: () => {
view.delete();
},
class: { 'delete-item': true },
}),
],
}),
],
},
});
};
renderMore = (count: number) => {
const views = this.viewManager.views$.value;
if (count === views.length) {
if (this.readonly) {
return;
}
return html`<div
class="database-view-button dv-icon-16 dv-hover"
data-testid="database-add-view-button"
@click="${this._addViewMenu}"
>
${PlusIcon()}
</div>`;
}
return html`
<div class="database-view-button dv-hover" @click="${this._showMore}">
${views.length - count} More
</div>
`;
};
renderViews = () => {
const views = this.viewManager.views$.value;
return views.map(id => () => {
const classList = classMap({
'database-view-button': true,
'dv-hover': true,
selected: this.viewManager.currentViewId$.value === id,
});
const view = this.viewManager.viewDataGet(id);
return html`
<div
class="${classList}"
style="margin-right: 4px;"
@click="${(event: MouseEvent) => this._clickView(event, id)}"
>
<uni-lit class="icon" .uni="${this.getRenderer(id).icon}"></uni-lit>
<div class="name">${view?.name}</div>
</div>
`;
});
};
get readonly() {
return this.viewManager.readonly$.value;
}
private getRenderer(viewId: string) {
return this.dataSource.viewMetaGetById(viewId).renderer;
}
_clickView(event: MouseEvent, id: string) {
if (this.viewManager.currentViewId$.value !== id) {
this.viewManager.setCurrentView(id);
this.onChangeView?.(id);
return;
}
this.openViewOption(
popupTargetFromElement(event.currentTarget as HTMLElement),
id
);
}
override render() {
return html`
<component-overflow
.renderItem="${this.renderViews()}"
.renderMore="${this.renderMore}"
></component-overflow>
`;
}
@property({ attribute: false })
accessor onChangeView: ((id: string) => void) | undefined;
}
declare global {
interface HTMLElementTagNameMap {
'data-view-header-views': DataViewHeaderViews;
}
}