mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
feat: support to show condition in filter chip label (#4751)
* feat: support to show condition in filter chip label * fix: improve the performance of inline formula with selection
This commit is contained in:
parent
980bebf86a
commit
e250f780a4
@ -106,6 +106,13 @@ export const useFiltersCount = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function useStaticTypeOption<T>(fieldId: string) {
|
||||||
|
const context = useContext(DatabaseContext);
|
||||||
|
const typeOptions = context.typeOptions;
|
||||||
|
|
||||||
|
return typeOptions[fieldId] as T;
|
||||||
|
}
|
||||||
|
|
||||||
export function useTypeOption<T>(fieldId: string) {
|
export function useTypeOption<T>(fieldId: string) {
|
||||||
const context = useContext(DatabaseContext);
|
const context = useContext(DatabaseContext);
|
||||||
const typeOptions = useSnapshot(context.typeOptions);
|
const typeOptions = useSnapshot(context.typeOptions);
|
||||||
|
@ -15,6 +15,7 @@ import ExpandRecordModal from '$app/components/database/components/edit_record/E
|
|||||||
import { subscribeNotifications } from '$app/application/notification';
|
import { subscribeNotifications } from '$app/application/notification';
|
||||||
import { Page } from '$app_reducers/pages/slice';
|
import { Page } from '$app_reducers/pages/slice';
|
||||||
import { getPage } from '$app/application/folder/page.service';
|
import { getPage } from '$app/application/folder/page.service';
|
||||||
|
import './database.scss';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
selectedViewId?: string;
|
selectedViewId?: string;
|
||||||
|
@ -8,9 +8,11 @@ interface Props {
|
|||||||
|
|
||||||
export const DatabaseCollection = ({ open }: Props) => {
|
export const DatabaseCollection = ({ open }: Props) => {
|
||||||
return (
|
return (
|
||||||
<div className={`flex items-center gap-2 px-16 ${!open ? 'hidden' : 'py-3'}`}>
|
<div className={`database-collection w-full px-[64px] ${!open ? 'hidden' : 'py-3'}`}>
|
||||||
<Sorts />
|
<div className={'flex w-full items-center gap-2 overflow-x-auto overflow-y-hidden '}>
|
||||||
<Filters />
|
<Sorts />
|
||||||
|
<Filters />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,20 +1,20 @@
|
|||||||
import React, { FC, useMemo, useState } from 'react';
|
import React, { FC, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Filter as FilterType,
|
|
||||||
Field as FieldData,
|
|
||||||
UndeterminedFilter,
|
|
||||||
TextFilterData,
|
|
||||||
SelectFilterData,
|
|
||||||
NumberFilterData,
|
|
||||||
CheckboxFilterData,
|
CheckboxFilterData,
|
||||||
ChecklistFilterData,
|
ChecklistFilterData,
|
||||||
DateFilterData,
|
DateFilterData,
|
||||||
|
Field as FieldData,
|
||||||
|
Filter as FilterType,
|
||||||
|
NumberFilterData,
|
||||||
|
SelectFilterData,
|
||||||
|
TextFilterData,
|
||||||
|
UndeterminedFilter,
|
||||||
} from '$app/application/database';
|
} from '$app/application/database';
|
||||||
import { Chip, Popover } from '@mui/material';
|
import { Chip, Popover } from '@mui/material';
|
||||||
import { Property } from '$app/components/database/components/property';
|
import { Property } from '$app/components/database/components/property';
|
||||||
import { ReactComponent as DropDownSvg } from '$app/assets/dropdown.svg';
|
import { ReactComponent as DropDownSvg } from '$app/assets/dropdown.svg';
|
||||||
import TextFilter from './text_filter/TextFilter';
|
import TextFilter from './text_filter/TextFilter';
|
||||||
import { FieldType } from '@/services/backend';
|
import { CheckboxFilterConditionPB, ChecklistFilterConditionPB, FieldType } from '@/services/backend';
|
||||||
import FilterActions from '$app/components/database/components/filter/FilterActions';
|
import FilterActions from '$app/components/database/components/filter/FilterActions';
|
||||||
import { updateFilter } from '$app/application/database/filter/filter_service';
|
import { updateFilter } from '$app/application/database/filter/filter_service';
|
||||||
import { useViewId } from '$app/hooks';
|
import { useViewId } from '$app/hooks';
|
||||||
@ -22,6 +22,11 @@ import SelectFilter from './select_filter/SelectFilter';
|
|||||||
|
|
||||||
import DateFilter from '$app/components/database/components/filter/date_filter/DateFilter';
|
import DateFilter from '$app/components/database/components/filter/date_filter/DateFilter';
|
||||||
import FilterConditionSelect from '$app/components/database/components/filter/FilterConditionSelect';
|
import FilterConditionSelect from '$app/components/database/components/filter/FilterConditionSelect';
|
||||||
|
import TextFilterValue from '$app/components/database/components/filter/text_filter/TextFilterValue';
|
||||||
|
import SelectFilterValue from '$app/components/database/components/filter/select_filter/SelectFilterValue';
|
||||||
|
import NumberFilterValue from '$app/components/database/components/filter/number_filter/NumberFilterValue';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import DateFilterValue from '$app/components/database/components/filter/date_filter/DateFilterValue';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
filter: FilterType;
|
filter: FilterType;
|
||||||
@ -57,6 +62,7 @@ const getFilterComponent = (field: FieldData) => {
|
|||||||
|
|
||||||
function Filter({ filter, field }: Props) {
|
function Filter({ filter, field }: Props) {
|
||||||
const viewId = useViewId();
|
const viewId = useViewId();
|
||||||
|
const { t } = useTranslation();
|
||||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||||
const open = Boolean(anchorEl);
|
const open = Boolean(anchorEl);
|
||||||
const handleClick = (e: React.MouseEvent<HTMLElement>) => {
|
const handleClick = (e: React.MouseEvent<HTMLElement>) => {
|
||||||
@ -70,7 +76,10 @@ function Filter({ filter, field }: Props) {
|
|||||||
const onDataChange = async (data: UndeterminedFilter['data']) => {
|
const onDataChange = async (data: UndeterminedFilter['data']) => {
|
||||||
const newFilter = {
|
const newFilter = {
|
||||||
...filter,
|
...filter,
|
||||||
data,
|
data: {
|
||||||
|
...(filter.data || {}),
|
||||||
|
...data,
|
||||||
|
},
|
||||||
} as UndeterminedFilter;
|
} as UndeterminedFilter;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -105,14 +114,42 @@ function Filter({ filter, field }: Props) {
|
|||||||
}
|
}
|
||||||
}, [field, filter]);
|
}, [field, filter]);
|
||||||
|
|
||||||
|
const conditionValue = useMemo(() => {
|
||||||
|
switch (field.type) {
|
||||||
|
case FieldType.RichText:
|
||||||
|
case FieldType.URL:
|
||||||
|
return <TextFilterValue data={filter.data as TextFilterData} />;
|
||||||
|
case FieldType.SingleSelect:
|
||||||
|
case FieldType.MultiSelect:
|
||||||
|
return <SelectFilterValue data={filter.data as SelectFilterData} fieldId={field.id} />;
|
||||||
|
case FieldType.Number:
|
||||||
|
return <NumberFilterValue data={filter.data as NumberFilterData} />;
|
||||||
|
case FieldType.Checkbox:
|
||||||
|
return (filter.data as CheckboxFilterData).condition === CheckboxFilterConditionPB.IsChecked
|
||||||
|
? t('grid.checkboxFilter.isChecked')
|
||||||
|
: t('grid.checkboxFilter.isUnchecked');
|
||||||
|
case FieldType.Checklist:
|
||||||
|
return (filter.data as ChecklistFilterData).condition === ChecklistFilterConditionPB.IsComplete
|
||||||
|
? t('grid.checklistFilter.isComplete')
|
||||||
|
: t('grid.checklistFilter.isIncomplted');
|
||||||
|
case FieldType.DateTime:
|
||||||
|
case FieldType.LastEditedTime:
|
||||||
|
case FieldType.CreatedTime:
|
||||||
|
return <DateFilterValue data={filter.data as DateFilterData} />;
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}, [field.id, field.type, filter.data, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Chip
|
<Chip
|
||||||
clickable
|
clickable
|
||||||
variant='outlined'
|
variant='outlined'
|
||||||
label={
|
label={
|
||||||
<div className={'flex items-center justify-center gap-1'}>
|
<div className={'flex items-center justify-between gap-1'}>
|
||||||
<Property field={field} />
|
<Property className={'flex flex-1 items-center'} field={field} />
|
||||||
|
<span className={'max-w-[120px] truncate'}>{conditionValue}</span>
|
||||||
<DropDownSvg className={'h-6 w-6'} />
|
<DropDownSvg className={'h-6 w-6'} />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@ -29,9 +29,9 @@ function Filters() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'flex items-center justify-center gap-2 text-text-title'}>
|
<div className={'flex flex-1 items-center gap-2 text-text-title'}>
|
||||||
{options.map(({ filter, field }) => (field ? <Filter key={filter.id} filter={filter} field={field} /> : null))}
|
{options.map(({ filter, field }) => (field ? <Filter key={filter.id} filter={filter} field={field} /> : null))}
|
||||||
<Button size={'small'} onClick={handleClick} color={'inherit'} startIcon={<AddSvg />}>
|
<Button size={'small'} className={'min-w-[100px]'} onClick={handleClick} color={'inherit'} startIcon={<AddSvg />}>
|
||||||
{t('grid.settings.addFilter')}
|
{t('grid.settings.addFilter')}
|
||||||
</Button>
|
</Button>
|
||||||
<FilterFieldsMenu
|
<FilterFieldsMenu
|
||||||
|
@ -27,23 +27,19 @@ function DateFilter({ filter, field, onChange }: Props) {
|
|||||||
const condition = filter.data.condition;
|
const condition = filter.data.condition;
|
||||||
const isRange = condition === DateFilterConditionPB.DateWithIn;
|
const isRange = condition === DateFilterConditionPB.DateWithIn;
|
||||||
const timestamp = useMemo(() => {
|
const timestamp = useMemo(() => {
|
||||||
const now = Date.now() / 1000;
|
|
||||||
|
|
||||||
if (isRange) {
|
if (isRange) {
|
||||||
return filter.data.start ? filter.data.start : now;
|
return filter.data.start;
|
||||||
}
|
}
|
||||||
|
|
||||||
return filter.data.timestamp ? filter.data.timestamp : now;
|
return filter.data.timestamp;
|
||||||
}, [filter.data.start, filter.data.timestamp, isRange]);
|
}, [filter.data.start, filter.data.timestamp, isRange]);
|
||||||
|
|
||||||
const endTimestamp = useMemo(() => {
|
const endTimestamp = useMemo(() => {
|
||||||
const now = Date.now() / 1000;
|
|
||||||
|
|
||||||
if (isRange) {
|
if (isRange) {
|
||||||
return filter.data.end ? filter.data.end : now;
|
return filter.data.end;
|
||||||
}
|
}
|
||||||
|
|
||||||
return now;
|
return;
|
||||||
}, [filter.data.end, isRange]);
|
}, [filter.data.end, isRange]);
|
||||||
|
|
||||||
const timeFormat = useMemo(() => {
|
const timeFormat = useMemo(() => {
|
||||||
@ -64,7 +60,7 @@ function DateFilter({ filter, field, onChange }: Props) {
|
|||||||
onChange({
|
onChange({
|
||||||
condition,
|
condition,
|
||||||
timestamp: date,
|
timestamp: date,
|
||||||
start: date,
|
start: endDate ? date : undefined,
|
||||||
end: endDate,
|
end: endDate,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
@ -81,7 +77,7 @@ function DateFilter({ filter, field, onChange }: Props) {
|
|||||||
onChange({
|
onChange({
|
||||||
condition,
|
condition,
|
||||||
timestamp: date,
|
timestamp: date,
|
||||||
start: date,
|
start: endDate ? date : undefined,
|
||||||
end: endDate,
|
end: endDate,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
@ -0,0 +1,52 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { DateFilterData } from '$app/application/database';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { DateFilterConditionPB } from '@/services/backend';
|
||||||
|
|
||||||
|
function DateFilterValue({ data }: { data: DateFilterData }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const value = useMemo(() => {
|
||||||
|
if (!data.timestamp) return '';
|
||||||
|
|
||||||
|
let startStr = '';
|
||||||
|
let endStr = '';
|
||||||
|
|
||||||
|
if (data.start) {
|
||||||
|
const end = data.end ?? data.start;
|
||||||
|
const moreThanOneYear = dayjs.unix(end).diff(dayjs.unix(data.start), 'year') > 1;
|
||||||
|
const format = moreThanOneYear ? 'MMM D, YYYY' : 'MMM D';
|
||||||
|
|
||||||
|
startStr = dayjs.unix(data.start).format(format);
|
||||||
|
endStr = dayjs.unix(end).format(format);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = dayjs.unix(data.timestamp).format('MMM D');
|
||||||
|
|
||||||
|
switch (data.condition) {
|
||||||
|
case DateFilterConditionPB.DateIs:
|
||||||
|
return `: ${timestamp}`;
|
||||||
|
case DateFilterConditionPB.DateBefore:
|
||||||
|
return `: ${t('grid.dateFilter.choicechipPrefix.before')} ${timestamp}`;
|
||||||
|
case DateFilterConditionPB.DateAfter:
|
||||||
|
return `: ${t('grid.dateFilter.choicechipPrefix.after')} ${timestamp}`;
|
||||||
|
case DateFilterConditionPB.DateOnOrBefore:
|
||||||
|
return `: ${t('grid.dateFilter.choicechipPrefix.onOrBefore')} ${timestamp}`;
|
||||||
|
case DateFilterConditionPB.DateOnOrAfter:
|
||||||
|
return `: ${t('grid.dateFilter.choicechipPrefix.onOrAfter')} ${timestamp}`;
|
||||||
|
case DateFilterConditionPB.DateWithIn:
|
||||||
|
return `: ${startStr} - ${endStr}`;
|
||||||
|
case DateFilterConditionPB.DateIsEmpty:
|
||||||
|
return `: ${t('grid.dateFilter.choicechipPrefix.isEmpty')}`;
|
||||||
|
case DateFilterConditionPB.DateIsNotEmpty:
|
||||||
|
return `: ${t('grid.dateFilter.choicechipPrefix.isNotEmpty')}`;
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}, [data, t]);
|
||||||
|
|
||||||
|
return <>{value}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DateFilterValue;
|
@ -0,0 +1,39 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { NumberFilterData } from '$app/application/database';
|
||||||
|
import { NumberFilterConditionPB } from '@/services/backend';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
function NumberFilterValue({ data }: { data: NumberFilterData }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const value = useMemo(() => {
|
||||||
|
if (!data.content) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = parseInt(data.content);
|
||||||
|
|
||||||
|
switch (data.condition) {
|
||||||
|
case NumberFilterConditionPB.Equal:
|
||||||
|
return `= ${content}`;
|
||||||
|
case NumberFilterConditionPB.NotEqual:
|
||||||
|
return `!= ${content}`;
|
||||||
|
case NumberFilterConditionPB.GreaterThan:
|
||||||
|
return `> ${content}`;
|
||||||
|
case NumberFilterConditionPB.GreaterThanOrEqualTo:
|
||||||
|
return `>= ${content}`;
|
||||||
|
case NumberFilterConditionPB.LessThan:
|
||||||
|
return `< ${content}`;
|
||||||
|
case NumberFilterConditionPB.LessThanOrEqualTo:
|
||||||
|
return `<= ${content}`;
|
||||||
|
case NumberFilterConditionPB.NumberIsEmpty:
|
||||||
|
return t('grid.textFilter.isEmpty');
|
||||||
|
case NumberFilterConditionPB.NumberIsNotEmpty:
|
||||||
|
return t('grid.textFilter.isNotEmpty');
|
||||||
|
}
|
||||||
|
}, [data.condition, data.content, t]);
|
||||||
|
|
||||||
|
return <>{value}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NumberFilterValue;
|
@ -0,0 +1,38 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { SelectFilterData, SelectTypeOption } from '$app/application/database';
|
||||||
|
import { useStaticTypeOption } from '$app/components/database';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { SelectOptionConditionPB } from '@/services/backend';
|
||||||
|
|
||||||
|
function SelectFilterValue({ data, fieldId }: { data: SelectFilterData; fieldId: string }) {
|
||||||
|
const typeOption = useStaticTypeOption<SelectTypeOption>(fieldId);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const value = useMemo(() => {
|
||||||
|
if (!data.optionIds?.length) return '';
|
||||||
|
|
||||||
|
const options = data.optionIds
|
||||||
|
.map((optionId) => {
|
||||||
|
const option = typeOption?.options?.find((option) => option.id === optionId);
|
||||||
|
|
||||||
|
return option?.name;
|
||||||
|
})
|
||||||
|
.join(', ');
|
||||||
|
|
||||||
|
switch (data.condition) {
|
||||||
|
case SelectOptionConditionPB.OptionIs:
|
||||||
|
return `: ${options}`;
|
||||||
|
case SelectOptionConditionPB.OptionIsNot:
|
||||||
|
return `: ${t('grid.textFilter.choicechipPrefix.isNot')} ${options}`;
|
||||||
|
case SelectOptionConditionPB.OptionIsEmpty:
|
||||||
|
return `: ${t('grid.textFilter.choicechipPrefix.isEmpty')}`;
|
||||||
|
case SelectOptionConditionPB.OptionIsNotEmpty:
|
||||||
|
return `: ${t('grid.textFilter.choicechipPrefix.isNotEmpty')}`;
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}, [data.condition, data.optionIds, t, typeOption?.options]);
|
||||||
|
|
||||||
|
return <>{value}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SelectFilterValue;
|
@ -0,0 +1,34 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { TextFilterData } from '$app/application/database';
|
||||||
|
import { TextFilterConditionPB } from '@/services/backend';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
function TextFilterValue({ data }: { data: TextFilterData }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const value = useMemo(() => {
|
||||||
|
if (!data.content) return '';
|
||||||
|
switch (data.condition) {
|
||||||
|
case TextFilterConditionPB.Contains:
|
||||||
|
case TextFilterConditionPB.Is:
|
||||||
|
return `: ${data.content}`;
|
||||||
|
case TextFilterConditionPB.DoesNotContain:
|
||||||
|
case TextFilterConditionPB.IsNot:
|
||||||
|
return `: ${t('grid.textFilter.choicechipPrefix.isNot')} ${data.content}`;
|
||||||
|
case TextFilterConditionPB.StartsWith:
|
||||||
|
return `: ${t('grid.textFilter.choicechipPrefix.startWith')} ${data.content}`;
|
||||||
|
case TextFilterConditionPB.EndsWith:
|
||||||
|
return `: ${t('grid.textFilter.choicechipPrefix.endWith')} ${data.content}`;
|
||||||
|
case TextFilterConditionPB.TextIsEmpty:
|
||||||
|
return `: ${t('grid.textFilter.choicechipPrefix.isEmpty')}`;
|
||||||
|
case TextFilterConditionPB.TextIsNotEmpty:
|
||||||
|
return `: ${t('grid.textFilter.choicechipPrefix.isNotEmpty')}`;
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}, [t, data]);
|
||||||
|
|
||||||
|
return <>{value}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TextFilterValue;
|
@ -10,6 +10,7 @@ export interface FieldProps {
|
|||||||
menuOpened?: boolean;
|
menuOpened?: boolean;
|
||||||
onOpenMenu?: (id: string) => void;
|
onOpenMenu?: (id: string) => void;
|
||||||
onCloseMenu?: (id: string) => void;
|
onCloseMenu?: (id: string) => void;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialAnchorOrigin: PopoverOrigin = {
|
const initialAnchorOrigin: PopoverOrigin = {
|
||||||
@ -22,7 +23,7 @@ const initialTransformOrigin: PopoverOrigin = {
|
|||||||
horizontal: 'left',
|
horizontal: 'left',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Property: FC<FieldProps> = ({ field, onCloseMenu, menuOpened }) => {
|
export const Property: FC<FieldProps> = ({ field, onCloseMenu, className, menuOpened }) => {
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
const [anchorPosition, setAnchorPosition] = useState<
|
const [anchorPosition, setAnchorPosition] = useState<
|
||||||
| {
|
| {
|
||||||
@ -63,7 +64,7 @@ export const Property: FC<FieldProps> = ({ field, onCloseMenu, menuOpened }) =>
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div ref={ref} className='flex w-full items-center px-2'>
|
<div ref={ref} className={className ? className : `flex w-full items-center px-2`}>
|
||||||
<ProppertyTypeSvg className='mr-1 text-base' type={field.type} />
|
<ProppertyTypeSvg className='mr-1 text-base' type={field.type} />
|
||||||
<span className='flex-1 truncate text-left text-xs'>{field.name}</span>
|
<span className='flex-1 truncate text-left text-xs'>{field.name}</span>
|
||||||
</div>
|
</div>
|
||||||
|
@ -0,0 +1,6 @@
|
|||||||
|
.database-collection {
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 0px;
|
||||||
|
height: 0px;
|
||||||
|
}
|
||||||
|
}
|
@ -1,7 +1,6 @@
|
|||||||
import { ReactEditor } from 'slate-react';
|
import { ReactEditor } from 'slate-react';
|
||||||
import { Editor, Element as SlateElement, NodeEntry, Range, Transforms } from 'slate';
|
import { Editor, Element, Element as SlateElement, NodeEntry, Range, Transforms } from 'slate';
|
||||||
import { EditorInlineNodeType, FormulaNode } from '$app/application/document/document.types';
|
import { EditorInlineNodeType, FormulaNode } from '$app/application/document/document.types';
|
||||||
import { isMarkActive } from '$app/components/editor/command/mark';
|
|
||||||
|
|
||||||
export function insertFormula(editor: ReactEditor, formula?: string) {
|
export function insertFormula(editor: ReactEditor, formula?: string) {
|
||||||
if (editor.selection) {
|
if (editor.selection) {
|
||||||
@ -80,5 +79,11 @@ export function unwrapFormula(editor: ReactEditor) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isFormulaActive(editor: ReactEditor) {
|
export function isFormulaActive(editor: ReactEditor) {
|
||||||
return isMarkActive(editor, EditorInlineNodeType.Formula);
|
const [match] = editor.nodes({
|
||||||
|
match: (n) => {
|
||||||
|
return !Editor.isEditor(n) && Element.isElement(n) && n.type === EditorInlineNodeType.Formula;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return Boolean(match);
|
||||||
}
|
}
|
||||||
|
@ -229,6 +229,16 @@ export const CustomEditor = {
|
|||||||
return !!match;
|
return !!match;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
formulaActiveNode(editor: ReactEditor) {
|
||||||
|
const [match] = editor.nodes({
|
||||||
|
match: (n) => {
|
||||||
|
return !Editor.isEditor(n) && Element.isElement(n) && n.type === EditorInlineNodeType.Formula;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return match ? (match as NodeEntry<FormulaNode>) : undefined;
|
||||||
|
},
|
||||||
|
|
||||||
isMentionActive(editor: ReactEditor) {
|
isMentionActive(editor: ReactEditor) {
|
||||||
const [match] = editor.nodes({
|
const [match] = editor.nodes({
|
||||||
match: (n) => {
|
match: (n) => {
|
||||||
@ -519,6 +529,14 @@ export const CustomEditor = {
|
|||||||
return editor.isEmpty(textNode);
|
return editor.isEmpty(textNode);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
includeInlineBlocks: (editor: ReactEditor) => {
|
||||||
|
const [match] = Editor.nodes(editor, {
|
||||||
|
match: (n) => Element.isElement(n) && editor.isInline(n),
|
||||||
|
});
|
||||||
|
|
||||||
|
return Boolean(match);
|
||||||
|
},
|
||||||
|
|
||||||
getNodeTextContent(node: Node): string {
|
getNodeTextContent(node: Node): string {
|
||||||
if (Element.isElement(node) && node.type === EditorInlineNodeType.Formula) {
|
if (Element.isElement(node) && node.type === EditorInlineNodeType.Formula) {
|
||||||
return (node as FormulaNode).data || '';
|
return (node as FormulaNode).data || '';
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { ReactEditor } from 'slate-react';
|
import { ReactEditor } from 'slate-react';
|
||||||
import { Editor, Text, Range } from 'slate';
|
import { Editor, Text, Range, Element } from 'slate';
|
||||||
import { EditorInlineNodeType, EditorMarkFormat } from '$app/application/document/document.types';
|
import { EditorInlineNodeType, EditorMarkFormat } from '$app/application/document/document.types';
|
||||||
|
|
||||||
export function toggleMark(
|
export function toggleMark(
|
||||||
@ -33,18 +33,13 @@ export function isMarkActive(editor: ReactEditor, format: EditorMarkFormat | Edi
|
|||||||
const isExpanded = Range.isExpanded(selection);
|
const isExpanded = Range.isExpanded(selection);
|
||||||
|
|
||||||
if (isExpanded) {
|
if (isExpanded) {
|
||||||
const matches = Array.from(getSelectionNodeEntry(editor) || []);
|
const texts = getSelectionTexts(editor);
|
||||||
|
|
||||||
return matches.every((match) => {
|
|
||||||
const [node] = match;
|
|
||||||
|
|
||||||
|
return texts.every((node) => {
|
||||||
const { text, ...attributes } = node;
|
const { text, ...attributes } = node;
|
||||||
|
|
||||||
if (!text) {
|
if (!text) return true;
|
||||||
return true;
|
return Boolean((attributes as Record<string, boolean | string>)[format]);
|
||||||
}
|
|
||||||
|
|
||||||
return !!(attributes as Record<string, boolean | string>)[format];
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,10 +48,12 @@ export function isMarkActive(editor: ReactEditor, format: EditorMarkFormat | Edi
|
|||||||
return marks ? !!marks[format] : false;
|
return marks ? !!marks[format] : false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSelectionNodeEntry(editor: ReactEditor) {
|
function getSelectionTexts(editor: ReactEditor) {
|
||||||
const selection = editor.selection;
|
const selection = editor.selection;
|
||||||
|
|
||||||
if (!selection) return null;
|
if (!selection) return [];
|
||||||
|
|
||||||
|
const texts: Text[] = [];
|
||||||
|
|
||||||
const isExpanded = Range.isExpanded(selection);
|
const isExpanded = Range.isExpanded(selection);
|
||||||
|
|
||||||
@ -73,16 +70,25 @@ function getSelectionNodeEntry(editor: ReactEditor) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Editor.nodes(editor, {
|
Array.from(
|
||||||
match: Text.isText,
|
Editor.nodes(editor, {
|
||||||
at: {
|
at: {
|
||||||
anchor,
|
anchor,
|
||||||
focus,
|
focus,
|
||||||
},
|
},
|
||||||
|
})
|
||||||
|
).forEach((match) => {
|
||||||
|
const node = match[0] as Element;
|
||||||
|
|
||||||
|
if (Text.isText(node)) {
|
||||||
|
texts.push(node);
|
||||||
|
} else if (Editor.isInline(editor, node)) {
|
||||||
|
texts.push(...(node.children as Text[]));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return texts;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -97,13 +103,11 @@ export function getAllMarks(editor: ReactEditor) {
|
|||||||
const isExpanded = Range.isExpanded(selection);
|
const isExpanded = Range.isExpanded(selection);
|
||||||
|
|
||||||
if (isExpanded) {
|
if (isExpanded) {
|
||||||
const matches = Array.from(getSelectionNodeEntry(editor) || []);
|
const texts = getSelectionTexts(editor);
|
||||||
|
|
||||||
const marks: Record<string, string | boolean> = {};
|
const marks: Record<string, string | boolean> = {};
|
||||||
|
|
||||||
matches.forEach((match) => {
|
texts.forEach((node) => {
|
||||||
const [node] = match;
|
|
||||||
|
|
||||||
Object.entries(node).forEach(([key, value]) => {
|
Object.entries(node).forEach(([key, value]) => {
|
||||||
if (key !== 'text') {
|
if (key !== 'text') {
|
||||||
marks[key] = value;
|
marks[key] = value;
|
||||||
|
@ -8,7 +8,7 @@ function BulletedListIcon({ block: _, className }: { block: BulletedListNode; cl
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}}
|
}}
|
||||||
contentEditable={false}
|
contentEditable={false}
|
||||||
className={`${className} bulleted-icon flex w-[23px] justify-center pr-1 font-medium`}
|
className={`${className} bulleted-icon flex min-w-[23px] justify-center pr-1 font-medium`}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -36,7 +36,7 @@ function NumberListIcon({ block, className }: { block: NumberedListNode; classNa
|
|||||||
}}
|
}}
|
||||||
contentEditable={false}
|
contentEditable={false}
|
||||||
data-number={index}
|
data-number={index}
|
||||||
className={`${className} numbered-icon flex w-[23px] justify-center pr-1 font-medium`}
|
className={`${className} numbered-icon flex w-[23px] min-w-[23px] justify-center pr-1 font-medium`}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -16,7 +16,7 @@ export const InlineFormula = memo(
|
|||||||
const { popoverOpen = false, setRange, openPopover, closePopover } = useEditorInlineBlockState('formula');
|
const { popoverOpen = false, setRange, openPopover, closePopover } = useEditorInlineBlockState('formula');
|
||||||
const anchor = useRef<HTMLSpanElement | null>(null);
|
const anchor = useRef<HTMLSpanElement | null>(null);
|
||||||
const selected = useSelected();
|
const selected = useSelected();
|
||||||
const open = popoverOpen && selected;
|
const open = Boolean(popoverOpen && selected);
|
||||||
|
|
||||||
const handleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
(e: MouseEvent<HTMLSpanElement>) => {
|
(e: MouseEvent<HTMLSpanElement>) => {
|
||||||
|
@ -3,7 +3,7 @@ import { MutableRefObject, useCallback, useEffect, useMemo, useRef, useState } f
|
|||||||
import { getSelectionPosition } from '$app/components/editor/components/tools/selection_toolbar/utils';
|
import { getSelectionPosition } from '$app/components/editor/components/tools/selection_toolbar/utils';
|
||||||
import debounce from 'lodash-es/debounce';
|
import debounce from 'lodash-es/debounce';
|
||||||
import { CustomEditor } from '$app/components/editor/command';
|
import { CustomEditor } from '$app/components/editor/command';
|
||||||
import { BaseRange, Editor, Range as SlateRange } from 'slate';
|
import { BaseRange, Range as SlateRange } from 'slate';
|
||||||
import { useDecorateDispatch } from '$app/components/editor/stores/decorate';
|
import { useDecorateDispatch } from '$app/components/editor/stores/decorate';
|
||||||
|
|
||||||
const DELAY = 300;
|
const DELAY = 300;
|
||||||
@ -118,9 +118,21 @@ export function useSelectionToolbar(ref: MutableRefObject<HTMLDivElement | null>
|
|||||||
|
|
||||||
const { selection } = editor;
|
const { selection } = editor;
|
||||||
|
|
||||||
if (!isFocusedEditor || !selection || SlateRange.isCollapsed(selection) || Editor.string(editor, selection) === '') {
|
const close = () => {
|
||||||
debounceRecalculatePosition.cancel();
|
debounceRecalculatePosition.cancel();
|
||||||
closeToolbar();
|
closeToolbar();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isFocusedEditor || !selection || SlateRange.isCollapsed(selection)) {
|
||||||
|
close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// There has a bug which the text of selection is empty when the selection include inline blocks
|
||||||
|
const isEmptyText = !CustomEditor.includeInlineBlocks(editor) && editor.string(selection) === '';
|
||||||
|
|
||||||
|
if (isEmptyText) {
|
||||||
|
close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -11,6 +11,7 @@ export function Bold() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const editor = useSlateStatic();
|
const editor = useSlateStatic();
|
||||||
const isActivated = CustomEditor.isMarkActive(editor, EditorMarkFormat.Bold);
|
const isActivated = CustomEditor.isMarkActive(editor, EditorMarkFormat.Bold);
|
||||||
|
|
||||||
const modifier = useMemo(() => getHotKey(EditorMarkFormat.Bold).modifier, []);
|
const modifier = useMemo(() => getHotKey(EditorMarkFormat.Bold).modifier, []);
|
||||||
const onClick = useCallback(() => {
|
const onClick = useCallback(() => {
|
||||||
CustomEditor.toggleMark(editor, {
|
CustomEditor.toggleMark(editor, {
|
||||||
|
@ -11,20 +11,28 @@ export function Formula() {
|
|||||||
const editor = useSlateStatic();
|
const editor = useSlateStatic();
|
||||||
const isActivatedMention = CustomEditor.isMentionActive(editor);
|
const isActivatedMention = CustomEditor.isMentionActive(editor);
|
||||||
|
|
||||||
|
const formulaMatch = CustomEditor.formulaActiveNode(editor);
|
||||||
const isActivated = !isActivatedMention && CustomEditor.isFormulaActive(editor);
|
const isActivated = !isActivatedMention && CustomEditor.isFormulaActive(editor);
|
||||||
|
|
||||||
const { setRange, openPopover } = useEditorInlineBlockState('formula');
|
const { setRange, openPopover } = useEditorInlineBlockState('formula');
|
||||||
const onClick = useCallback(() => {
|
const onClick = useCallback(() => {
|
||||||
const selection = editor.selection;
|
let selection = editor.selection;
|
||||||
|
|
||||||
if (!selection) return;
|
if (!selection) return;
|
||||||
CustomEditor.toggleFormula(editor);
|
if (formulaMatch) {
|
||||||
|
selection = editor.range(formulaMatch[1]);
|
||||||
|
editor.select(selection);
|
||||||
|
} else {
|
||||||
|
CustomEditor.toggleFormula(editor);
|
||||||
|
}
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
|
if (!selection) return;
|
||||||
|
|
||||||
setRange(selection);
|
setRange(selection);
|
||||||
openPopover();
|
openPopover();
|
||||||
});
|
});
|
||||||
}, [editor, setRange, openPopover]);
|
}, [editor, formulaMatch, setRange, openPopover]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ActionButton
|
<ActionButton
|
||||||
|
@ -4,7 +4,7 @@ import { generateId } from '$app/components/editor/provider/utils/convert';
|
|||||||
import { YDelta2Delta } from '$app/components/editor/provider/utils/delta';
|
import { YDelta2Delta } from '$app/components/editor/provider/utils/delta';
|
||||||
import { YDelta } from '$app/components/editor/provider/types/y_event';
|
import { YDelta } from '$app/components/editor/provider/types/y_event';
|
||||||
import { getInsertTarget, getYTarget } from '$app/components/editor/provider/utils/relation';
|
import { getInsertTarget, getYTarget } from '$app/components/editor/provider/utils/relation';
|
||||||
import { EditorNodeType } from '$app/application/document/document.types';
|
import { EditorInlineNodeType, EditorNodeType } from '$app/application/document/document.types';
|
||||||
import { Log } from '$app/utils/log';
|
import { Log } from '$app/utils/log';
|
||||||
|
|
||||||
export function YEvents2BlockActions(
|
export function YEvents2BlockActions(
|
||||||
@ -36,6 +36,30 @@ export function YEvent2BlockActions(
|
|||||||
const backupTarget = getYTarget(backupDoc, path) as Readonly<Y.XmlText>;
|
const backupTarget = getYTarget(backupDoc, path) as Readonly<Y.XmlText>;
|
||||||
const actions = [];
|
const actions = [];
|
||||||
|
|
||||||
|
if ([EditorInlineNodeType.Formula, EditorInlineNodeType.Mention].includes(yXmlText.getAttribute('type'))) {
|
||||||
|
const parentYXmlText = yXmlText.parent as Y.XmlText;
|
||||||
|
const parentDelta = parentYXmlText.toDelta() as YDelta;
|
||||||
|
const index = parentDelta.findIndex((op) => op.insert === yXmlText);
|
||||||
|
const ops = YDelta2Delta(parentDelta);
|
||||||
|
|
||||||
|
const retainIndex = ops.reduce((acc, op, currentIndex) => {
|
||||||
|
if (currentIndex < index) {
|
||||||
|
return acc + (op.insert as string).length ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const newDelta = [
|
||||||
|
{
|
||||||
|
retain: retainIndex,
|
||||||
|
},
|
||||||
|
...delta,
|
||||||
|
];
|
||||||
|
|
||||||
|
actions.push(...generateApplyTextActions(parentYXmlText, newDelta));
|
||||||
|
}
|
||||||
|
|
||||||
if (yXmlText.getAttribute('type') === 'text') {
|
if (yXmlText.getAttribute('type') === 'text') {
|
||||||
actions.push(...textOps2BlockActions(rootId, yXmlText, delta));
|
actions.push(...textOps2BlockActions(rootId, yXmlText, delta));
|
||||||
}
|
}
|
||||||
|
@ -11,7 +11,7 @@ export function transformToInlineElement(op: Op): Element | null {
|
|||||||
const attributes = op.attributes;
|
const attributes = op.attributes;
|
||||||
|
|
||||||
if (!attributes) return null;
|
if (!attributes) return null;
|
||||||
const formula = attributes.formula as string;
|
const { formula, mention, ...attrs } = attributes;
|
||||||
|
|
||||||
if (formula) {
|
if (formula) {
|
||||||
return {
|
return {
|
||||||
@ -20,23 +20,23 @@ export function transformToInlineElement(op: Op): Element | null {
|
|||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
text: op.insert as string,
|
text: op.insert as string,
|
||||||
|
...attrs,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const matchMention = attributes.mention as Mention;
|
if (mention) {
|
||||||
|
|
||||||
if (matchMention) {
|
|
||||||
return {
|
return {
|
||||||
type: EditorInlineNodeType.Mention,
|
type: EditorInlineNodeType.Mention,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
text: op.insert as string,
|
text: op.insert as string,
|
||||||
|
...attrs,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
data: {
|
data: {
|
||||||
...matchMention,
|
...(mention as Mention),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { createContext, useCallback, useContext, useMemo } from 'react';
|
import { createContext, useCallback, useContext, useMemo } from 'react';
|
||||||
import { BaseRange } from 'slate';
|
import { BaseRange, Path } from 'slate';
|
||||||
import { proxy, useSnapshot } from 'valtio';
|
import { proxy, useSnapshot } from 'valtio';
|
||||||
|
|
||||||
export interface EditorInlineBlockState {
|
export interface EditorInlineBlockState {
|
||||||
@ -43,8 +43,10 @@ export function useEditorInlineBlockState(key: 'formula') {
|
|||||||
}, [context, key]);
|
}, [context, key]);
|
||||||
|
|
||||||
const setRange = useCallback(
|
const setRange = useCallback(
|
||||||
(range: BaseRange) => {
|
(at: BaseRange | Path) => {
|
||||||
context[key].range = range;
|
const range = Path.isPath(at) ? { anchor: at, focus: at } : at;
|
||||||
|
|
||||||
|
context[key].range = range as BaseRange;
|
||||||
},
|
},
|
||||||
[context, key]
|
[context, key]
|
||||||
);
|
);
|
||||||
|
@ -550,7 +550,15 @@
|
|||||||
"onOrAfter": "Is on or after",
|
"onOrAfter": "Is on or after",
|
||||||
"between": "Is between",
|
"between": "Is between",
|
||||||
"empty": "Is empty",
|
"empty": "Is empty",
|
||||||
"notEmpty": "Is not empty"
|
"notEmpty": "Is not empty",
|
||||||
|
"choicechipPrefix": {
|
||||||
|
"before": "Before",
|
||||||
|
"after": "After",
|
||||||
|
"onOrBefore": "On or before",
|
||||||
|
"onOrAfter": "On or after",
|
||||||
|
"isEmpty": "Is empty",
|
||||||
|
"isNotEmpty": "Is not empty"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"numberFilter": {
|
"numberFilter": {
|
||||||
"equal": "Equals",
|
"equal": "Equals",
|
||||||
|
Loading…
Reference in New Issue
Block a user