mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-06 11:59:51 +08:00
chore: merge blocksuite source code (#9213)
This commit is contained in:
@@ -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({}));
|
||||
+308
@@ -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;
|
||||
}
|
||||
}
|
||||
+476
@@ -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;
|
||||
}
|
||||
}
|
||||
+426
@@ -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;
|
||||
}
|
||||
}
|
||||
+43
@@ -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()}
|
||||
`;
|
||||
}
|
||||
}
|
||||
+379
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user