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:
Kilu.He 2024-02-28 14:26:08 +08:00 committed by GitHub
parent 980bebf86a
commit e250f780a4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 366 additions and 71 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,6 @@
.database-collection {
::-webkit-scrollbar {
width: 0px;
height: 0px;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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>) => {

View File

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

View File

@ -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, {

View File

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

View File

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

View File

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

View File

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

View File

@ -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",