mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-19 19:16:29 +08:00
init: the first public commit for AFFiNE
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
import { services } from '@toeverything/datasource/db-service';
|
||||
import { useUserAndSpaces } from '@toeverything/datasource/state';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import {
|
||||
MuiList as List,
|
||||
MuiListItem as ListItem,
|
||||
MuiListItemText as ListItemText,
|
||||
MuiListItemButton as ListItemButton,
|
||||
} from '@toeverything/components/ui';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
const StyledWrapper = styled('div')({
|
||||
margin: '0 16px 0 32px',
|
||||
span: {
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
'.item': {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
ustifyContent: 'space-between',
|
||||
padding: '7px 0px',
|
||||
whiteSpace: 'nowrap',
|
||||
},
|
||||
'.itemButton': {
|
||||
padding: 0,
|
||||
height: 32,
|
||||
},
|
||||
'.itemLeft': {
|
||||
color: '#4c6275',
|
||||
marginRight: '20px',
|
||||
span: {
|
||||
fontSize: 14,
|
||||
},
|
||||
},
|
||||
'.itemRight': {
|
||||
color: '#B6C7D3',
|
||||
flex: 'none',
|
||||
span: {
|
||||
fontSize: 12,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const Activities = () => {
|
||||
const { user, currentSpaceId } = useUserAndSpaces();
|
||||
const [recenPages, setRecentPages] = useState([]);
|
||||
|
||||
const fetchRecentPages = useCallback(async () => {
|
||||
if (!user || !currentSpaceId) {
|
||||
return;
|
||||
}
|
||||
const recent_pages = await services.api.userConfig.getRecentPages(
|
||||
currentSpaceId,
|
||||
user.id
|
||||
);
|
||||
setRecentPages(recent_pages);
|
||||
}, [user, currentSpaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRecentPages();
|
||||
}, [user, currentSpaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
let unobserve: () => void;
|
||||
const observe = async () => {
|
||||
unobserve = await services.api.userConfig.observe(
|
||||
{ workspace: currentSpaceId },
|
||||
() => {
|
||||
fetchRecentPages();
|
||||
}
|
||||
);
|
||||
};
|
||||
observe();
|
||||
|
||||
return () => {
|
||||
unobserve?.();
|
||||
};
|
||||
}, [currentSpaceId, fetchRecentPages]);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<List>
|
||||
{recenPages.map(({ id, title, lastOpenTime }) => {
|
||||
return (
|
||||
<ListItem className="item" key={id}>
|
||||
<ListItemButton
|
||||
className="itemButton"
|
||||
onClick={() => {
|
||||
navigate(`/${currentSpaceId}/${id}`);
|
||||
}}
|
||||
>
|
||||
<ListItemText
|
||||
className="itemLeft"
|
||||
primary={title}
|
||||
/>
|
||||
<ListItemText
|
||||
className="itemRight"
|
||||
primary={formatDistanceToNow(lastOpenTime, {
|
||||
includeSeconds: true,
|
||||
})}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './activities';
|
||||
@@ -0,0 +1,100 @@
|
||||
import { ComponentType, useEffect } from 'react';
|
||||
import {
|
||||
styled,
|
||||
MuiBox as Box,
|
||||
MuiTextField as TextField,
|
||||
// CalendarPickerSkeleton,
|
||||
LocalizationProvider,
|
||||
AdapterDateFns,
|
||||
StaticDatePicker,
|
||||
} from '@toeverything/components/ui';
|
||||
|
||||
import type { Theme } from './types';
|
||||
import { useCalendarHeatmap } from './use-calendar-heatmap';
|
||||
|
||||
export type CalendarHeatmapProps = {
|
||||
calendarTheme?: Theme;
|
||||
};
|
||||
|
||||
/** Calendar heat map component, different colors represent the number of operation records on the day */
|
||||
export function CalendarHeatmap({ calendarTheme }: CalendarHeatmapProps) {
|
||||
const {
|
||||
fetchHighlightedDays,
|
||||
pickDay,
|
||||
setPickDay,
|
||||
handleMonthChange,
|
||||
renderDayWithHeatmap,
|
||||
} = useCalendarHeatmap({ calendarTheme });
|
||||
|
||||
useEffect(() => {
|
||||
fetchHighlightedDays();
|
||||
}, [fetchHighlightedDays]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
id="affineCalendarHeatmapContainer"
|
||||
sx={{
|
||||
// flex: 1,
|
||||
'.MuiPickerStaticWrapper-root': {
|
||||
minWidth: 240,
|
||||
},
|
||||
'.MuiCalendarPicker-root': {
|
||||
width: 280,
|
||||
margin: 0,
|
||||
marginLeft: '-8px',
|
||||
'& > div': {
|
||||
marginTop: 0,
|
||||
},
|
||||
'& .PrivatePickersFadeTransitionGroup-root > div': {
|
||||
color: '#98acbd',
|
||||
fontSize: '0.8rem',
|
||||
},
|
||||
"& > div > div[role='presentation']": {
|
||||
marginLeft: '8px',
|
||||
},
|
||||
"& div[role='presentation'] > button:first-of-type": {
|
||||
display: 'none',
|
||||
},
|
||||
'& .PrivatePickersSlideTransition-root': {
|
||||
minHeight: 290,
|
||||
},
|
||||
'& .MuiIconButton-sizeSmall svg': {
|
||||
color: '#98acbd',
|
||||
},
|
||||
"& div[class][style*='cubic-bezier']": {
|
||||
marginRight: '8px',
|
||||
},
|
||||
"& div[class][style*='cubic-bezier'] > div": {
|
||||
width: '8px',
|
||||
},
|
||||
'& .PrivatePickersFadeTransitionGroup-root.MuiCalendarPicker-viewTransitionContainer':
|
||||
{
|
||||
height: 260,
|
||||
},
|
||||
'& .MuiCalendarPicker-viewTransitionContainer .MuiTypography-caption':
|
||||
{
|
||||
width: 30,
|
||||
height: 30,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<LocalizationProvider dateAdapter={AdapterDateFns}>
|
||||
<StaticDatePicker
|
||||
value={pickDay}
|
||||
onChange={setPickDay}
|
||||
displayStaticWrapperAs="desktop"
|
||||
onMonthChange={handleMonthChange}
|
||||
label="Week picker"
|
||||
inputFormat="'Week of' MMM d"
|
||||
renderInput={params => <TextField {...(params as any)} />}
|
||||
renderDay={renderDayWithHeatmap}
|
||||
disableHighlightToday={true}
|
||||
autoFocus={false}
|
||||
// loading={loading}
|
||||
// renderLoading={() => <CalendarPickerSkeleton />}
|
||||
/>
|
||||
</LocalizationProvider>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import {
|
||||
styled,
|
||||
PickersDay,
|
||||
type PickersDayProps,
|
||||
} from '@toeverything/components/ui';
|
||||
|
||||
import type { Theme } from './types';
|
||||
import { DEFAULT_THEME } from './utils';
|
||||
|
||||
type HeatedDayProps = PickersDayProps<Date> & {
|
||||
activitiesOfDay: number;
|
||||
calendarTheme: Theme;
|
||||
};
|
||||
|
||||
export const HeatedDay = styled(PickersDay, {
|
||||
shouldForwardProp: (prop: string) =>
|
||||
!['activitiesOfDay', 'calendarTheme'].includes(prop),
|
||||
})<HeatedDayProps>(({ calendarTheme = DEFAULT_THEME, activitiesOfDay }) => ({
|
||||
...{
|
||||
width: 30,
|
||||
height: 30,
|
||||
margin: '0 2.5px 16px 2.5px',
|
||||
borderRadius: 3,
|
||||
backgroundColor: '#fff',
|
||||
color: '#4E687C',
|
||||
// fontSize: '0.8rem'
|
||||
},
|
||||
...(activitiesOfDay > 0 && {
|
||||
backgroundColor: calendarTheme.level1,
|
||||
'&:hover': {
|
||||
backgroundColor: calendarTheme.level1,
|
||||
},
|
||||
}),
|
||||
...(activitiesOfDay > 3 && {
|
||||
backgroundColor: calendarTheme.level2,
|
||||
'&:hover': {
|
||||
backgroundColor: calendarTheme.level2,
|
||||
},
|
||||
}),
|
||||
...(activitiesOfDay > 6 && {
|
||||
backgroundColor: calendarTheme.level3,
|
||||
color: '#fff',
|
||||
'&:hover': {
|
||||
backgroundColor: calendarTheme.level3,
|
||||
},
|
||||
}),
|
||||
})) as unknown as ComponentType<HeatedDayProps>;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { CalendarHeatmap } from './CalendarHeatmap';
|
||||
export { useCalendarHeatmap } from './use-calendar-heatmap';
|
||||
@@ -0,0 +1,17 @@
|
||||
export type CalendarDay = {
|
||||
level?: Level;
|
||||
/** Date number, 1-31 */
|
||||
dayInMonth: number;
|
||||
/** The number of current operation records, currently using the number of pages currently created */
|
||||
activitiesOfDay: number;
|
||||
};
|
||||
|
||||
export type Level = 1 | 2 | 3 | 4 | 5;
|
||||
|
||||
export type Theme = {
|
||||
readonly level1: string;
|
||||
readonly level2: string;
|
||||
readonly level3: string;
|
||||
readonly level4?: string;
|
||||
readonly level5?: string;
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useCallback, useEffect, useState, createElement } from 'react';
|
||||
import { atom, useAtom } from 'jotai';
|
||||
import type { PickersDayProps } from '@toeverything/components/ui';
|
||||
|
||||
import type { CalendarDay } from './types';
|
||||
import type { CalendarHeatmapProps } from './CalendarHeatmap';
|
||||
import { HeatedDay } from './HeatedDay';
|
||||
import { fakeFetch, fetchActivitiesHeatmap } from './utils';
|
||||
// import { usePageTree } from 'PageTree';
|
||||
|
||||
const highlightedDaysAtom = atom<CalendarDay[] | undefined>([]);
|
||||
|
||||
export const useCalendarHeatmap = ({
|
||||
calendarTheme,
|
||||
}: CalendarHeatmapProps = {}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [highlightedDays, setHighlightedDays] = useAtom(highlightedDaysAtom);
|
||||
const [pickDay, setPickDay] = useState<Date | null>(null);
|
||||
|
||||
// const { flattenedItems } = usePageTree();
|
||||
|
||||
const fetchHighlightedDays = useCallback(
|
||||
async (date?: Date) => {
|
||||
// const { daysToHighlight } = await fetchActivitiesHeatmap(db, date || new Date(), flattenedItems);
|
||||
// setHighlightedDays(daysToHighlight);
|
||||
setHighlightedDays([]);
|
||||
// setLoading(false);
|
||||
},
|
||||
[setHighlightedDays]
|
||||
);
|
||||
|
||||
const handleMonthChange = useCallback(
|
||||
(date: Date) => {
|
||||
// setLoading(true);
|
||||
// setHighlightedDays([]);
|
||||
fetchHighlightedDays(date);
|
||||
},
|
||||
[fetchHighlightedDays]
|
||||
);
|
||||
|
||||
const addPageToday = useCallback(() => {
|
||||
const foundToday = highlightedDays?.find(
|
||||
hDay => hDay.dayInMonth === new Date().getDate()
|
||||
);
|
||||
if (foundToday) {
|
||||
setHighlightedDays([
|
||||
...highlightedDays.filter(
|
||||
day => day.dayInMonth !== foundToday.dayInMonth
|
||||
),
|
||||
{
|
||||
...foundToday,
|
||||
activitiesOfDay: foundToday.activitiesOfDay + 1,
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
setHighlightedDays([
|
||||
...highlightedDays,
|
||||
{ dayInMonth: new Date().getDate(), activitiesOfDay: 1 },
|
||||
]);
|
||||
}
|
||||
}, [highlightedDays, setHighlightedDays]);
|
||||
|
||||
const renderDayWithHeatmap = useCallback(
|
||||
(
|
||||
day: Date,
|
||||
selectedDates: Array<Date | null>,
|
||||
pickersDayProps: PickersDayProps<Date>
|
||||
) => {
|
||||
const foundDay = highlightedDays?.find(
|
||||
hDay => hDay.dayInMonth === day.getDate()
|
||||
);
|
||||
const isSelected = !pickersDayProps.outsideCurrentMonth && foundDay;
|
||||
|
||||
return createElement(HeatedDay, {
|
||||
...pickersDayProps,
|
||||
calendarTheme,
|
||||
activitiesOfDay: isSelected ? foundDay.activitiesOfDay : 0,
|
||||
});
|
||||
// return (
|
||||
// <HeatedDay
|
||||
// {...pickersDayProps}
|
||||
// calendarTheme={calendarTheme}
|
||||
// activitiesOfDay={isSelected ? foundDay.activitiesOfDay : 0}
|
||||
// />
|
||||
// );
|
||||
},
|
||||
[highlightedDays, calendarTheme]
|
||||
);
|
||||
|
||||
return {
|
||||
loading,
|
||||
fetchHighlightedDays,
|
||||
pickDay,
|
||||
setPickDay,
|
||||
handleMonthChange,
|
||||
renderDayWithHeatmap,
|
||||
addPageToday,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
import type {
|
||||
BlockClientInstance,
|
||||
BlockImplInstance,
|
||||
} from '@toeverything/datasource/jwt';
|
||||
import { getDateIsoStringWithTimezone } from '@toeverything/utils';
|
||||
import getDaysInMonth from 'date-fns/getDaysInMonth';
|
||||
import color, { ColorInput } from 'tinycolor2';
|
||||
|
||||
import type { Theme, CalendarDay } from './types';
|
||||
|
||||
// export const DEFAULT_THEME = createCalendarTheme('#3E6FDB');
|
||||
export const DEFAULT_THEME: Theme = {
|
||||
level4: '#3E6FDB',
|
||||
level3: 'rgba(62, 111, 219)',
|
||||
level2: 'rgba(62, 111, 219, 0.5)',
|
||||
level1: 'rgba(62, 111, 219, 0.2)',
|
||||
};
|
||||
|
||||
export function createCalendarTheme(
|
||||
baseColor: ColorInput,
|
||||
emptyColor = color('white').darken(8).toHslString()
|
||||
): Theme {
|
||||
const base = color(baseColor);
|
||||
|
||||
if (!base.isValid()) {
|
||||
return DEFAULT_THEME;
|
||||
}
|
||||
|
||||
return {
|
||||
level4: base.setAlpha(0.92).toHslString(),
|
||||
level3: base.setAlpha(0.76).toHslString(),
|
||||
level2: base.setAlpha(0.6).toHslString(),
|
||||
level1: base.setAlpha(0.44).toHslString(),
|
||||
// level0: emptyColor
|
||||
};
|
||||
}
|
||||
|
||||
function getRandomNumber(min: number, max: number) {
|
||||
return Math.round(Math.random() * (max - min) + min);
|
||||
}
|
||||
|
||||
export async function fetchActivitiesHeatmap(
|
||||
db: BlockClientInstance,
|
||||
date: Date,
|
||||
flattenedItems: any[]
|
||||
): Promise<{ daysToHighlight: CalendarDay[] }> {
|
||||
// const pages = await db.getByType('page');
|
||||
const pages_with_ids = (await Promise.all(
|
||||
flattenedItems.map(async (page_item: any) => {
|
||||
const page_id = page_item.id;
|
||||
return [page_id, await db.get(page_id as 'page')];
|
||||
})
|
||||
)) as [string, BlockImplInstance][];
|
||||
const pages = new Map(pages_with_ids);
|
||||
|
||||
const blocks = pages.values();
|
||||
const pages_by_month = {} as Record<string, BlockImplInstance[]>;
|
||||
|
||||
for (const page of blocks) {
|
||||
const page_created_month = getDateIsoStringWithTimezone(
|
||||
page.created
|
||||
).slice(0, 7);
|
||||
if (!pages_by_month[page_created_month]) {
|
||||
pages_by_month[page_created_month] = [page];
|
||||
} else {
|
||||
pages_by_month[page_created_month].push(page);
|
||||
}
|
||||
}
|
||||
|
||||
const result_date_key = getDateIsoStringWithTimezone(date.getTime()).slice(
|
||||
0,
|
||||
7
|
||||
);
|
||||
if (!pages_by_month[result_date_key]) {
|
||||
return { daysToHighlight: [] };
|
||||
}
|
||||
|
||||
const pages_by_day = {} as Record<string, BlockImplInstance[]>;
|
||||
const daysToHighlight = [] as CalendarDay[];
|
||||
pages_by_month[result_date_key].forEach(page => {
|
||||
const page_created_date = new Date(page.created).getDate();
|
||||
if (!pages_by_day[page_created_date]) {
|
||||
pages_by_day[page_created_date] = [page];
|
||||
} else {
|
||||
pages_by_day[page_created_date].push(page);
|
||||
}
|
||||
});
|
||||
|
||||
Object.keys(pages_by_day).forEach(day => {
|
||||
daysToHighlight.push({
|
||||
dayInMonth: Number(day),
|
||||
activitiesOfDay: pages_by_day[day].length,
|
||||
});
|
||||
});
|
||||
|
||||
return { daysToHighlight };
|
||||
}
|
||||
|
||||
export async function fakeFetch(
|
||||
date: Date,
|
||||
{ signal }: { signal?: AbortSignal } = {}
|
||||
): Promise<{ daysToHighlight: CalendarDay[] }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// const timeout = setTimeout(() => {
|
||||
const daysInMonth = getDaysInMonth(date);
|
||||
const daysToHighlight = Array(7)
|
||||
.fill(1)
|
||||
.map(() => {
|
||||
const countOfDay = getRandomNumber(1, daysInMonth);
|
||||
return {
|
||||
dayInMonth: countOfDay,
|
||||
activitiesOfDay: countOfDay,
|
||||
};
|
||||
});
|
||||
resolve({ daysToHighlight });
|
||||
// }, 0);
|
||||
|
||||
if (signal) {
|
||||
signal.onabort = () => {
|
||||
// clearTimeout(timeout);
|
||||
reject(new DOMException('aborted', 'AbortError'));
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './page-tree';
|
||||
export * from './calendar-heatmap';
|
||||
export * from './activities';
|
||||
@@ -0,0 +1,142 @@
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
DropAnimation,
|
||||
MeasuringStrategy,
|
||||
PointerSensor,
|
||||
defaultDropAnimation,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
|
||||
import { DndTreeItem } from './tree-item';
|
||||
import type { TreeItems } from './types';
|
||||
import { usePageTree } from './use-page-tree';
|
||||
import { getChildCount } from './utils';
|
||||
|
||||
const measuring = {
|
||||
droppable: {
|
||||
strategy: MeasuringStrategy.Always,
|
||||
},
|
||||
};
|
||||
|
||||
const dropAnimation: DropAnimation = {
|
||||
...defaultDropAnimation,
|
||||
// dragSourceOpacity: 0.5,
|
||||
};
|
||||
|
||||
export type DndTreeProps = {
|
||||
defaultItems?: TreeItems;
|
||||
indentationWidth?: number;
|
||||
collapsible?: boolean;
|
||||
removable?: boolean;
|
||||
showDragIndicator?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Currently does not support drag and drop using the keyboard.
|
||||
*/
|
||||
export function DndTree(props: DndTreeProps) {
|
||||
const {
|
||||
indentationWidth = 16,
|
||||
collapsible,
|
||||
removable,
|
||||
showDragIndicator,
|
||||
} = props;
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } })
|
||||
);
|
||||
|
||||
const {
|
||||
items,
|
||||
activeId,
|
||||
flattenedItems,
|
||||
projected,
|
||||
handleDragStart,
|
||||
handleDragMove,
|
||||
handleDragOver,
|
||||
handleDragEnd,
|
||||
handleDragCancel,
|
||||
handleRemove,
|
||||
handleCollapse,
|
||||
} = usePageTree(props);
|
||||
|
||||
const sortedIds = useMemo(
|
||||
() => flattenedItems.map(({ id }) => id),
|
||||
[flattenedItems]
|
||||
);
|
||||
|
||||
const activeItem = useMemo(
|
||||
() =>
|
||||
activeId ? flattenedItems.find(({ id }) => id === activeId) : null,
|
||||
[activeId, flattenedItems]
|
||||
);
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
measuring={measuring}
|
||||
onDragStart={handleDragStart}
|
||||
onDragMove={handleDragMove}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
>
|
||||
<SortableContext
|
||||
items={sortedIds}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{/* <button onClick={() => handleAdd()}> add top node</button> */}
|
||||
{flattenedItems.map(
|
||||
({ id, title, children, collapsed, depth }) => (
|
||||
<DndTreeItem
|
||||
key={id}
|
||||
id={id}
|
||||
// value={id}
|
||||
value={title}
|
||||
collapsed={Boolean(collapsed && children.length)}
|
||||
depth={
|
||||
id === activeId && projected
|
||||
? projected.depth
|
||||
: depth
|
||||
}
|
||||
indentationWidth={indentationWidth}
|
||||
indicator={showDragIndicator}
|
||||
onCollapse={
|
||||
collapsible && children.length
|
||||
? () => handleCollapse(id)
|
||||
: undefined
|
||||
}
|
||||
onRemove={
|
||||
removable ? () => handleRemove(id) : undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<DragOverlay
|
||||
dropAnimation={dropAnimation}
|
||||
style={{ marginTop: '-65px' }}
|
||||
>
|
||||
{activeId && activeItem ? (
|
||||
<DndTreeItem
|
||||
id={activeId}
|
||||
// value={activeId}
|
||||
value={activeItem.title}
|
||||
depth={activeItem.depth}
|
||||
clone={true}
|
||||
childCount={getChildCount(items, activeId) + 1}
|
||||
indentationWidth={indentationWidth}
|
||||
/>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// import type {
|
||||
// ArrayOperation,
|
||||
// BlockClientInstance,
|
||||
// BlockImplInstance,
|
||||
// MapOperation,
|
||||
// } from '@toeverything/datasource/jwt';
|
||||
// import { getDateIsoStringWithTimezone } from '@toeverything/utils';
|
||||
|
||||
// export async function getRecentPages(
|
||||
// db: BlockClientInstance,
|
||||
// userId: string
|
||||
// ): Promise<string[]> {
|
||||
// try {
|
||||
// const config = await db.getWorkspaceConfig<MapOperation<string>>();
|
||||
// const recent_pages = config.get('recent_pages');
|
||||
// if (recent_pages?.get(userId)) {
|
||||
// return [recent_pages.get(userId) as string];
|
||||
// }
|
||||
// } catch (e) {
|
||||
// console.error(e);
|
||||
// }
|
||||
|
||||
// return [''];
|
||||
// }
|
||||
|
||||
// /** Mark the article corresponding to pageId as the most recently accessed article */
|
||||
// export async function setRecentPages(
|
||||
// db: BlockClientInstance,
|
||||
// userId: string,
|
||||
// pageId: string
|
||||
// ): Promise<void> {
|
||||
// try {
|
||||
// const config = await db.getWorkspaceConfig<MapOperation<string>>();
|
||||
// const recent_pages = config.get('recent_pages');
|
||||
// if (recent_pages && recent_pages.get(userId)) {
|
||||
// recent_pages.set(userId, pageId);
|
||||
// } else {
|
||||
// const map = config.createMap<string>();
|
||||
// map.set(userId, pageId);
|
||||
// config.set('recent_pages', map);
|
||||
// }
|
||||
// } catch (e) {
|
||||
// console.error(e);
|
||||
// }
|
||||
// }
|
||||
|
||||
// export async function setPageTree<TreeItem>(
|
||||
// db: BlockClientInstance,
|
||||
// treeData: TreeItem[]
|
||||
// ): Promise<void> {
|
||||
// try {
|
||||
// const config = await db.getWorkspaceConfig();
|
||||
// const array = config.createArray<TreeItem>();
|
||||
// array.push((treeData as any[]) || []);
|
||||
// config.set('page_tree', array);
|
||||
// } catch (e) {
|
||||
// console.error(e);
|
||||
// }
|
||||
// }
|
||||
|
||||
// async function update_tree_items_title<
|
||||
// TreeItem extends { id: string; title: string; children: TreeItem[] }
|
||||
// >(
|
||||
// db: BlockClientInstance,
|
||||
// items: TreeItem[],
|
||||
// cache: Record<string, string>
|
||||
// ): Promise<TreeItem[]> {
|
||||
// for (const item of items) {
|
||||
// if (cache[item.id]) {
|
||||
// item.title = cache[item.id];
|
||||
// } else {
|
||||
// const page = await db.get(item.id as 'page');
|
||||
// item.title =
|
||||
// page.getDecoration<Array<{ text: string }>>('text')?.[0]
|
||||
// ?.text ||
|
||||
// 'Untitled ' +
|
||||
// getDateIsoStringWithTimezone(page.created)
|
||||
// .slice(11)
|
||||
// .replace('T', ' ');
|
||||
// cache[item.id] = item.title;
|
||||
// }
|
||||
|
||||
// if (item.children.length) {
|
||||
// item.children = await update_tree_items_title(
|
||||
// db,
|
||||
// item.children,
|
||||
// cache
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
|
||||
// return [...items];
|
||||
// }
|
||||
|
||||
// export async function getPageTree<TreeItem>(
|
||||
// db: BlockClientInstance
|
||||
// ): Promise<TreeItem[]> {
|
||||
// try {
|
||||
// const config = await db.getWorkspaceConfig();
|
||||
// const page_tree = config.get('page_tree') as ArrayOperation<TreeItem>;
|
||||
// const page_tree_items = page_tree?.slice();
|
||||
// if (page_tree && page_tree_items?.length) {
|
||||
// const pages = await update_tree_items_title(
|
||||
// db,
|
||||
// page_tree_items as [],
|
||||
// {}
|
||||
// );
|
||||
// return pages;
|
||||
// }
|
||||
// } catch (e) {
|
||||
// console.error(e);
|
||||
// }
|
||||
|
||||
// return [];
|
||||
// }
|
||||
|
||||
// export async function getWorkspaceConfig(
|
||||
// db: BlockClientInstance,
|
||||
// userId: string,
|
||||
// name: string
|
||||
// ): Promise<string> {
|
||||
// try {
|
||||
// // const config = await db.getWorkspaceConfig();
|
||||
// // const map = config.get_map<string>(name);
|
||||
// // if (map && map.get(userId)) {
|
||||
// // return map.get(userId)!;
|
||||
// // }
|
||||
// } catch (e) {
|
||||
// console.error(e);
|
||||
// }
|
||||
|
||||
// return '';
|
||||
// }
|
||||
|
||||
// export async function setWorkspaceConfig(
|
||||
// db: BlockClientInstance,
|
||||
// userId: string,
|
||||
// name: string,
|
||||
// value: string
|
||||
// ): Promise<void> {
|
||||
// try {
|
||||
// // const config = await db.getWorkspaceConfig();
|
||||
// // const map = config.get_map<string>(name);
|
||||
// // map.set(userId, value === undefined || value === null ? '' : value);
|
||||
// } catch (e) {
|
||||
// console.error(e);
|
||||
// }
|
||||
// }
|
||||
|
||||
// export async function createPage(
|
||||
// db: BlockClientInstance,
|
||||
// title?: string,
|
||||
// content?: string,
|
||||
// metadata?: Record<string, string>
|
||||
// ): Promise<BlockImplInstance | undefined> {
|
||||
// if (db) {
|
||||
// const page: BlockImplInstance = await db.get('page');
|
||||
// if (title) {
|
||||
// page.getContent<string>().set('title', title);
|
||||
// }
|
||||
// if (metadata) {
|
||||
// Object.keys(metadata).forEach(property =>
|
||||
// page.getContent<string>().set(property, metadata[property])
|
||||
// );
|
||||
// }
|
||||
// return page;
|
||||
// }
|
||||
|
||||
// return undefined;
|
||||
// }
|
||||
@@ -0,0 +1,6 @@
|
||||
export { PageTree } from './page-tree';
|
||||
export { usePageTree } from './use-page-tree';
|
||||
|
||||
export * from './types';
|
||||
|
||||
// export { getRecentPages, setRecentPages } from './block-page';
|
||||
@@ -0,0 +1,22 @@
|
||||
import style9 from 'style9';
|
||||
|
||||
import { DndTree } from './DndTree';
|
||||
import { useDndTreeAutoUpdate } from './use-page-tree';
|
||||
|
||||
const styles = style9.create({
|
||||
root: {
|
||||
minWidth: 160,
|
||||
maxWidth: 260,
|
||||
marginLeft: 18,
|
||||
marginRight: 6,
|
||||
},
|
||||
});
|
||||
|
||||
export const PageTree = () => {
|
||||
useDndTreeAutoUpdate();
|
||||
return (
|
||||
<div className={styles('root')}>
|
||||
<DndTree collapsible removable />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
|
||||
import { iOS } from '../utils';
|
||||
import { TreeItem, TreeItemProps } from './TreeItem';
|
||||
|
||||
type DndTreeItemProps = TreeItemProps & {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export function DndTreeItem({ id, depth, ...props }: DndTreeItemProps) {
|
||||
const {
|
||||
attributes,
|
||||
isDragging,
|
||||
isSorting,
|
||||
listeners,
|
||||
setDraggableNodeRef,
|
||||
setDroppableNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
} = useSortable({ id });
|
||||
|
||||
const style: CSSProperties = {
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<TreeItem
|
||||
ref={setDraggableNodeRef}
|
||||
wrapperRef={setDroppableNodeRef}
|
||||
pageId={id}
|
||||
style={style}
|
||||
depth={depth}
|
||||
ghost={isDragging}
|
||||
disableSelection={iOS}
|
||||
disableInteraction={isSorting}
|
||||
handleProps={{
|
||||
...attributes,
|
||||
...listeners,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import styles from './tree-item.module.scss';
|
||||
import {
|
||||
MuiSnackbar as Snackbar,
|
||||
Cascader,
|
||||
CascaderItemProps,
|
||||
MuiDivider as Divider,
|
||||
} from '@toeverything/components/ui';
|
||||
import React from 'react';
|
||||
import { NavLink, useNavigate } from 'react-router-dom';
|
||||
import { copyToClipboard } from '@toeverything/utils';
|
||||
import { services, TemplateFactory } from '@toeverything/datasource/db-service';
|
||||
import { NewFromTemplatePortal } from './NewFromTemplatePortal';
|
||||
import { useFlag } from '@toeverything/datasource/feature-flags';
|
||||
const MESSAGES = {
|
||||
COPY_LINK_SUCCESS: 'Copyed link to clipboard',
|
||||
};
|
||||
interface ActionsProps {
|
||||
workspaceId: string;
|
||||
pageId: string;
|
||||
onRemove: () => void;
|
||||
}
|
||||
function DndTreeItemMoreActions(props: ActionsProps) {
|
||||
const [alert_open, set_alert_open] = React.useState(false);
|
||||
const [anchorEl, setAnchorEl] = React.useState<HTMLDivElement | null>(null);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const workspaceId = props.workspaceId;
|
||||
const pageId = props.pageId;
|
||||
const blockUrl = window.location.origin + `/${workspaceId}/${pageId}`;
|
||||
|
||||
const redirect_to_page = (new_workspaceId: string, newPageId: string) => {
|
||||
navigate('/' + new_workspaceId + '/' + newPageId);
|
||||
};
|
||||
|
||||
const handle_alert_close = () => {
|
||||
set_alert_open(false);
|
||||
};
|
||||
const handleClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
const open = Boolean(anchorEl);
|
||||
const handle_copy_link = () => {
|
||||
copyToClipboard(blockUrl);
|
||||
set_alert_open(true);
|
||||
handleClose();
|
||||
};
|
||||
const handle_delete = () => {
|
||||
props.onRemove();
|
||||
handleClose();
|
||||
};
|
||||
const handle_new_child_page = async () => {
|
||||
const new_page = await services.api.editorBlock.create({
|
||||
workspace: workspaceId,
|
||||
type: 'page' as const,
|
||||
});
|
||||
await services.api.pageTree.addChildPageToWorkspace(
|
||||
workspaceId,
|
||||
pageId,
|
||||
new_page.id
|
||||
);
|
||||
redirect_to_page(workspaceId, new_page.id);
|
||||
|
||||
handleClose();
|
||||
};
|
||||
const handle_new_prev_page = async () => {
|
||||
const new_page = await services.api.editorBlock.create({
|
||||
workspace: workspaceId,
|
||||
type: 'page' as const,
|
||||
});
|
||||
await services.api.pageTree.addPrevPageToWorkspace(
|
||||
workspaceId,
|
||||
pageId,
|
||||
new_page.id
|
||||
);
|
||||
|
||||
redirect_to_page(workspaceId, new_page.id);
|
||||
|
||||
handleClose();
|
||||
};
|
||||
const handle_new_next_page = async () => {
|
||||
const new_page = await services.api.editorBlock.create({
|
||||
workspace: workspaceId,
|
||||
type: 'page' as const,
|
||||
});
|
||||
await services.api.pageTree.addNextPageToWorkspace(
|
||||
workspaceId,
|
||||
pageId,
|
||||
new_page.id
|
||||
);
|
||||
|
||||
redirect_to_page(workspaceId, new_page.id);
|
||||
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handle_duplicate_page = async () => {
|
||||
//create page
|
||||
const new_page = await services.api.editorBlock.create({
|
||||
workspace: workspaceId,
|
||||
type: 'page' as const,
|
||||
});
|
||||
//add page to tree
|
||||
await services.api.pageTree.addNextPageToWorkspace(
|
||||
workspaceId,
|
||||
pageId,
|
||||
new_page.id
|
||||
);
|
||||
//copy source page to new page
|
||||
await services.api.editorBlock.copyPage(
|
||||
workspaceId,
|
||||
pageId,
|
||||
new_page.id
|
||||
);
|
||||
|
||||
redirect_to_page(workspaceId, new_page.id);
|
||||
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const redirectToPage = (newWorkspaceId: string, newPageId: string) => {
|
||||
navigate('/' + newWorkspaceId + '/' + newPageId);
|
||||
};
|
||||
|
||||
const handleNewFromTemplate = async template => {
|
||||
const newPage = await services.api.editorBlock.create({
|
||||
workspace: workspaceId,
|
||||
type: 'page' as const,
|
||||
});
|
||||
|
||||
await services.api.pageTree.addNextPageToWorkspace(
|
||||
workspaceId,
|
||||
pageId,
|
||||
newPage.id
|
||||
);
|
||||
|
||||
await services.api.editorBlock.copyTemplateToPage(
|
||||
workspaceId,
|
||||
newPage.id,
|
||||
TemplateFactory.generatePageTemplateByGroupKeys({
|
||||
name: template.name,
|
||||
groupKeys: template.groupKeys,
|
||||
})
|
||||
);
|
||||
|
||||
redirectToPage(workspaceId, newPage.id);
|
||||
};
|
||||
|
||||
const templateList = useFlag(
|
||||
'JSONTemplateList',
|
||||
TemplateFactory.defaultTemplateList
|
||||
);
|
||||
|
||||
const templateMenuList: CascaderItemProps[] = templateList.map(
|
||||
(template, index) => {
|
||||
const item = {
|
||||
title: template.name,
|
||||
callback: () => {
|
||||
handleNewFromTemplate(template);
|
||||
},
|
||||
};
|
||||
return item;
|
||||
}
|
||||
);
|
||||
|
||||
const menuList = [
|
||||
{
|
||||
title: 'Duplicate Page',
|
||||
callback: () => {
|
||||
handle_duplicate_page();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
isDivide: true,
|
||||
},
|
||||
{
|
||||
title: 'New Child Page',
|
||||
callback: () => {
|
||||
handle_new_child_page();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'New Prev Page',
|
||||
callback: () => {
|
||||
handle_new_prev_page();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'New Next Page',
|
||||
callback: () => {
|
||||
handle_new_next_page();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'New From Template',
|
||||
subItems: templateMenuList,
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
isDivide: true,
|
||||
},
|
||||
{
|
||||
title: 'Open In New Tab',
|
||||
callback: () => {
|
||||
const new_window = window.open(
|
||||
`/${workspaceId}/${pageId}`,
|
||||
'_blank'
|
||||
);
|
||||
if (new_window) {
|
||||
new_window.focus();
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
isDivide: true,
|
||||
},
|
||||
{
|
||||
title: 'Copy Link',
|
||||
callback: () => {
|
||||
handle_copy_link();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Delete',
|
||||
callback: () => {
|
||||
handle_delete();
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
className={styles['TreeItemMoreActions']}
|
||||
onClick={handleClick}
|
||||
>
|
||||
···
|
||||
</span>
|
||||
<Cascader
|
||||
items={menuList}
|
||||
anchorEl={anchorEl}
|
||||
placement="right-start"
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
></Cascader>
|
||||
<Snackbar
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
open={alert_open}
|
||||
message={MESSAGES.COPY_LINK_SUCCESS}
|
||||
key={'bottomcenter'}
|
||||
autoHideDuration={2000}
|
||||
onClose={handle_alert_close}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default DndTreeItemMoreActions;
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
import React, {
|
||||
useState,
|
||||
MouseEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { services, TemplateFactory } from '@toeverything/datasource/db-service';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { copyToClipboard } from '@toeverything/utils';
|
||||
import { ViewSidebarIcon } from '@toeverything/components/common';
|
||||
import {
|
||||
MuiSnackbar as Snackbar,
|
||||
MuiPopover as Popover,
|
||||
ListButton,
|
||||
MuiDivider as Divider,
|
||||
MuiSwitch as Switch,
|
||||
styled,
|
||||
BaseButton,
|
||||
} from '@toeverything/components/ui';
|
||||
import { useUserAndSpaces } from '@toeverything/datasource/state';
|
||||
import { useFlag } from '@toeverything/datasource/feature-flags';
|
||||
const NewFromTemplatePortalContainer = styled('div')({
|
||||
width: '320p',
|
||||
padding: '15px',
|
||||
'.textDescription': {
|
||||
height: '22px',
|
||||
lineHeight: '22px',
|
||||
marginLeft: '30px',
|
||||
fontSize: '14px',
|
||||
color: '#ccc',
|
||||
p: {
|
||||
margin: 0,
|
||||
},
|
||||
},
|
||||
'.switchDescription': {
|
||||
color: '#ccc',
|
||||
fontSize: '14px',
|
||||
paddingLeft: '30px',
|
||||
},
|
||||
});
|
||||
|
||||
const BtnPageSettingContainer = styled('div')({
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
});
|
||||
|
||||
const MESSAGES = {
|
||||
COPY_LINK: ' Copy Link',
|
||||
INVITE: 'Add people,emails, or groups',
|
||||
COPY_LINK_SUCCESS: 'Copyed link to clipboard',
|
||||
};
|
||||
|
||||
interface NewFromTemplatePortalProps {
|
||||
workspaceId: string;
|
||||
pageId: string;
|
||||
}
|
||||
function NewFromTemplatePortal(props: NewFromTemplatePortalProps) {
|
||||
const [alertOpen, setAlertOpen] = useState(false);
|
||||
const [anchorEl, setAnchorEl] = React.useState<HTMLDivElement | null>(null);
|
||||
|
||||
const params = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useUserAndSpaces();
|
||||
|
||||
const workspaceId = props.workspaceId;
|
||||
const pageId = props.pageId;
|
||||
const handleAlertClose = () => {
|
||||
setAlertOpen(false);
|
||||
};
|
||||
const redirectToPage = (newWorkspaceId: string, newPageId: string) => {
|
||||
navigate('/' + newWorkspaceId + '/' + newPageId);
|
||||
};
|
||||
|
||||
const handleNewFromTemplate = async template => {
|
||||
const newPage = await services.api.editorBlock.create({
|
||||
workspace: workspaceId,
|
||||
type: 'page' as const,
|
||||
});
|
||||
|
||||
await services.api.pageTree.addNextPageToWorkspace(
|
||||
workspaceId,
|
||||
pageId,
|
||||
newPage.id
|
||||
);
|
||||
|
||||
await services.api.editorBlock.copyTemplateToPage(
|
||||
workspaceId,
|
||||
newPage.id,
|
||||
TemplateFactory.generatePageTemplateByGroupKeys({
|
||||
name: template.name,
|
||||
groupKeys: template.groupKeys,
|
||||
})
|
||||
);
|
||||
|
||||
redirectToPage(workspaceId, newPage.id);
|
||||
|
||||
handleClose();
|
||||
};
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
const open = Boolean(anchorEl);
|
||||
const handleClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
const newFromTemplateRef = useRef();
|
||||
const templateList = useFlag(
|
||||
'JSONTemplateList',
|
||||
TemplateFactory.defaultTemplateList
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<div ref={newFromTemplateRef} onClick={handleClick}>
|
||||
<ListButton content="New From Template" onClick={() => {}} />
|
||||
</div>
|
||||
|
||||
<Popover
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
open={open}
|
||||
anchorEl={anchorEl}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<NewFromTemplatePortalContainer>
|
||||
<>
|
||||
{templateList.map((template, index) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => {
|
||||
handleNewFromTemplate(template);
|
||||
}}
|
||||
>
|
||||
<BaseButton>{template.name}</BaseButton>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
</NewFromTemplatePortalContainer>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export { NewFromTemplatePortal };
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
import React, {
|
||||
forwardRef,
|
||||
type CSSProperties,
|
||||
type HTMLAttributes,
|
||||
} from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import cx from 'clsx';
|
||||
import { CloseIcon, DocumentIcon } from '@toeverything/components/common';
|
||||
import {
|
||||
ArrowDropDownIcon,
|
||||
ArrowRightIcon,
|
||||
} from '@toeverything/components/icons';
|
||||
|
||||
import styles from './tree-item.module.scss';
|
||||
import { useFlag } from '@toeverything/datasource/feature-flags';
|
||||
|
||||
import MoreActions from './MoreActions';
|
||||
export type TreeItemProps = {
|
||||
/** The main text to display on this line */
|
||||
value: string;
|
||||
/** The layer number of the node, 0, 1, 2 */
|
||||
depth: number;
|
||||
/** The item in the DragOverlay is clone, the one in the normal list is not clone, and the delete icon is displayed through the clone control */
|
||||
clone?: boolean;
|
||||
pageId?: string;
|
||||
childCount?: number;
|
||||
collapsed?: boolean;
|
||||
disableInteraction?: boolean;
|
||||
disableSelection?: boolean;
|
||||
/** isDragging */
|
||||
ghost?: boolean;
|
||||
handleProps?: any;
|
||||
indicator?: boolean;
|
||||
indentationWidth: number;
|
||||
onCollapse?(): void;
|
||||
onRemove?(): void;
|
||||
/** The ref of the outermost container is often used as droppaHTMLAttributes<HTMLLIElement>ble-node; the ref of the inner dom is often used as draggable-node */
|
||||
wrapperRef?(node: HTMLLIElement): void;
|
||||
} & HTMLAttributes<HTMLLIElement>;
|
||||
|
||||
export const TreeItem = forwardRef<HTMLDivElement, TreeItemProps>(
|
||||
(
|
||||
{
|
||||
childCount,
|
||||
clone,
|
||||
depth,
|
||||
disableSelection,
|
||||
disableInteraction,
|
||||
ghost,
|
||||
handleProps,
|
||||
indentationWidth,
|
||||
indicator,
|
||||
collapsed,
|
||||
onCollapse,
|
||||
onRemove,
|
||||
style,
|
||||
value,
|
||||
wrapperRef,
|
||||
pageId,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const { workspace_id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const BooleanPageTreeItemMoreActions = useFlag(
|
||||
'BooleanPageTreeItemMoreActions',
|
||||
false
|
||||
);
|
||||
return (
|
||||
<li
|
||||
ref={wrapperRef}
|
||||
className={cx(
|
||||
styles['Wrapper'],
|
||||
clone && styles['clone'],
|
||||
ghost && styles['ghost'],
|
||||
indicator && styles['indicator'],
|
||||
disableSelection && styles['disableSelection'],
|
||||
disableInteraction && styles['disableInteraction']
|
||||
)}
|
||||
style={
|
||||
{
|
||||
'--spacing': `${indentationWidth * depth}px`,
|
||||
} as CSSProperties
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
ref={ref}
|
||||
className={styles['TreeItem']}
|
||||
style={style}
|
||||
title={value}
|
||||
>
|
||||
<Action onClick={onCollapse}>
|
||||
{collapsed ? <ArrowRightIcon /> : <ArrowDropDownIcon />}
|
||||
</Action>
|
||||
<Action>
|
||||
<DocumentIcon />
|
||||
</Action>
|
||||
<span
|
||||
className={styles['Text']}
|
||||
{...handleProps}
|
||||
onClick={() => {
|
||||
navigate(`/${workspace_id}/${pageId}`);
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
{BooleanPageTreeItemMoreActions && (
|
||||
<MoreActions
|
||||
workspaceId={workspace_id}
|
||||
pageId={pageId}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!clone && onRemove && <Remove onClick={onRemove} />}
|
||||
{clone && childCount && childCount > 1 ? (
|
||||
<span className={styles['Count']}>{childCount}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export interface ActionProps extends React.HTMLAttributes<HTMLButtonElement> {
|
||||
active?: {
|
||||
fill: string;
|
||||
background: string;
|
||||
};
|
||||
// cursor?: CSSProperties['cursor'];
|
||||
cursor?: 'pointer' | 'grab';
|
||||
}
|
||||
|
||||
/** Customizable buttons */
|
||||
export function Action({
|
||||
active,
|
||||
className,
|
||||
cursor,
|
||||
style,
|
||||
...props
|
||||
}: ActionProps) {
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
className={cx(styles['Action'], className)}
|
||||
tabIndex={0}
|
||||
style={
|
||||
{
|
||||
...style,
|
||||
// cursor,
|
||||
'--fill': active?.fill,
|
||||
'--background': active?.background,
|
||||
} as CSSProperties
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Handle(props: ActionProps) {
|
||||
return (
|
||||
<Action cursor="grab" data-cypress="draggable-handle" {...props}>
|
||||
<ArrowDropDownIcon />
|
||||
</Action>
|
||||
);
|
||||
}
|
||||
|
||||
export function Remove(props: ActionProps) {
|
||||
return (
|
||||
<Action
|
||||
{...props}
|
||||
active={{
|
||||
fill: 'rgba(255, 70, 70, 0.95)',
|
||||
background: 'rgba(255, 70, 70, 0.1)',
|
||||
}}
|
||||
>
|
||||
<CloseIcon style={{ fontSize: 12 }} />
|
||||
{/* <svg width="8" viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2.99998 -0.000206962C2.7441 -0.000206962 2.48794 0.0972617 2.29294 0.292762L0.292945 2.29276C-0.0980552 2.68376 -0.0980552 3.31682 0.292945 3.70682L7.58591 10.9998L0.292945 18.2928C-0.0980552 18.6838 -0.0980552 19.3168 0.292945 19.7068L2.29294 21.7068C2.68394 22.0978 3.31701 22.0978 3.70701 21.7068L11 14.4139L18.2929 21.7068C18.6829 22.0978 19.317 22.0978 19.707 21.7068L21.707 19.7068C22.098 19.3158 22.098 18.6828 21.707 18.2928L14.414 10.9998L21.707 3.70682C22.098 3.31682 22.098 2.68276 21.707 2.29276L19.707 0.292762C19.316 -0.0982383 18.6829 -0.0982383 18.2929 0.292762L11 7.58573L3.70701 0.292762C3.51151 0.0972617 3.25585 -0.000206962 2.99998 -0.000206962Z" />
|
||||
</svg> */}
|
||||
</Action>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { DndTreeItem } from './DndTreeItem';
|
||||
export { TreeItem } from './TreeItem';
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
.Wrapper {
|
||||
box-sizing: border-box;
|
||||
padding-left: var(--spacing);
|
||||
margin-bottom: -1px;
|
||||
list-style: none;
|
||||
padding: 6px 0;
|
||||
font-size: 14px;
|
||||
|
||||
&.clone {
|
||||
display: inline-block;
|
||||
padding: 0;
|
||||
margin-left: 10px;
|
||||
margin-top: 5px;
|
||||
pointer-events: none;
|
||||
|
||||
.TreeItem {
|
||||
padding-right: 20px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0px 15px 15px 0 rgba(34, 33, 81, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
&.ghost {
|
||||
&.indicator {
|
||||
opacity: 1;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-bottom: -1px;
|
||||
|
||||
.TreeItem {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
height: 8px;
|
||||
border-color: #2389ff;
|
||||
background-color: #56a1f8;
|
||||
|
||||
&:before {
|
||||
position: absolute;
|
||||
left: -8px;
|
||||
top: -4px;
|
||||
display: block;
|
||||
content: '';
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #2389ff;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
> * {
|
||||
/* Items are hidden using height and opacity to retain focus */
|
||||
opacity: 0;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.indicator) {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.TreeItem > * {
|
||||
box-shadow: none;
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.TreeItem {
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 0;
|
||||
background-color: #fff;
|
||||
color: #4c6275;
|
||||
|
||||
.TreeItemMoreActions {
|
||||
visibility: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
&:hover {
|
||||
.TreeItemMoreActions {
|
||||
visibility: visible;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.Text {
|
||||
flex-grow: 1;
|
||||
padding-left: 0.5rem;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.Count {
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
right: -10px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background-color: #2389ff;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.disableInteraction {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.disableSelection,
|
||||
.clone {
|
||||
.Text,
|
||||
.Count {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
.Collapse {
|
||||
svg {
|
||||
transition: transform 250ms ease;
|
||||
}
|
||||
|
||||
&.collapsed svg {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
}
|
||||
|
||||
.Action {
|
||||
display: flex;
|
||||
width: 12px;
|
||||
padding: 0 15px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
touch-action: none;
|
||||
cursor: pointer;
|
||||
border-radius: 5px;
|
||||
border: none;
|
||||
outline: none;
|
||||
appearance: none;
|
||||
background-color: transparent;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--action-background, rgba(0, 0, 0, 0.05));
|
||||
|
||||
svg {
|
||||
fill: #6f7b88;
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
flex: 0 0 auto;
|
||||
margin: auto;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
fill: #919eab;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--background, rgba(0, 0, 0, 0.05));
|
||||
|
||||
svg {
|
||||
fill: var(--fill, #788491);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0), 0 0px 0px 2px #4c9ffe;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { MutableRefObject } from 'react';
|
||||
|
||||
export type TreeItem = {
|
||||
/** page id */
|
||||
id: string;
|
||||
/** page title */
|
||||
title?: string;
|
||||
/** sub pages */
|
||||
children: TreeItem[];
|
||||
collapsed?: boolean;
|
||||
};
|
||||
|
||||
export type TreeItems = TreeItem[];
|
||||
|
||||
export type FlattenedItem = TreeItem & {
|
||||
index: number;
|
||||
/** parent page id */
|
||||
parentId: string | null;
|
||||
depth: number;
|
||||
};
|
||||
|
||||
export type SensorContext = MutableRefObject<{
|
||||
items: FlattenedItem[];
|
||||
offset: number;
|
||||
}>;
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useCallback, useMemo, useState, useEffect } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { atom, useAtom } from 'jotai';
|
||||
import type {
|
||||
DragEndEvent,
|
||||
DragMoveEvent,
|
||||
DragOverEvent,
|
||||
DragStartEvent,
|
||||
} from '@dnd-kit/core';
|
||||
import { arrayMove } from '@dnd-kit/sortable';
|
||||
|
||||
import { services } from '@toeverything/datasource/db-service';
|
||||
import type { DndTreeProps } from './DndTree';
|
||||
import type { FlattenedItem, TreeItem, TreeItems } from './types';
|
||||
import {
|
||||
buildTree,
|
||||
flattenTree,
|
||||
getProjection,
|
||||
removeChildrenOf,
|
||||
removeItem,
|
||||
setProperty,
|
||||
} from './utils';
|
||||
|
||||
const page_tree_atom = atom<TreeItems | undefined>([]);
|
||||
|
||||
export const usePageTree = ({ indentationWidth = 16 }: DndTreeProps = {}) => {
|
||||
const { workspace_id, page_id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [items] = useAtom(page_tree_atom);
|
||||
const [activeId, setActiveId] = useState<string | undefined>(undefined);
|
||||
const [overId, setOverId] = useState<string | undefined>(undefined);
|
||||
const [offsetLeft, setOffsetLeft] = useState<number>(0);
|
||||
|
||||
const flattenedItems = useMemo(() => {
|
||||
const flattenedTree = flattenTree(items);
|
||||
const collapsedItems = flattenedTree.reduce<string[]>(
|
||||
(acc, { children, collapsed, id }) =>
|
||||
collapsed && children.length ? [...acc, id] : acc,
|
||||
[]
|
||||
);
|
||||
return removeChildrenOf(
|
||||
flattenedTree,
|
||||
activeId ? [activeId, ...collapsedItems] : collapsedItems
|
||||
);
|
||||
}, [activeId, items]);
|
||||
|
||||
const projected = useMemo(
|
||||
() =>
|
||||
activeId && overId
|
||||
? getProjection(
|
||||
flattenedItems,
|
||||
activeId,
|
||||
overId,
|
||||
offsetLeft,
|
||||
indentationWidth
|
||||
)
|
||||
: null,
|
||||
[activeId, flattenedItems, indentationWidth, offsetLeft, overId]
|
||||
);
|
||||
|
||||
const savePageTreeData = useCallback(
|
||||
async (treeData?: TreeItem[]) => {
|
||||
await services.api.pageTree.setPageTree<TreeItem>(
|
||||
workspace_id,
|
||||
treeData || []
|
||||
);
|
||||
},
|
||||
[workspace_id]
|
||||
);
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setOverId(undefined);
|
||||
setActiveId(undefined);
|
||||
setOffsetLeft(0);
|
||||
|
||||
document.body.style.setProperty('cursor', '');
|
||||
}, []);
|
||||
|
||||
const handleDragStart = useCallback(({ active: { id: activeId } }) => {
|
||||
setActiveId(activeId);
|
||||
setOverId(activeId);
|
||||
|
||||
document.body.style.setProperty('cursor', 'grabbing');
|
||||
}, []);
|
||||
|
||||
const handleDragMove = useCallback(({ delta }: DragMoveEvent) => {
|
||||
setOffsetLeft(delta.x);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback(({ over }) => {
|
||||
setOverId(over?.id ?? null);
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
async ({ active, over }: DragEndEvent) => {
|
||||
resetState();
|
||||
|
||||
if (projected && over) {
|
||||
const { depth, parentId } = projected;
|
||||
const clonedItems: FlattenedItem[] = JSON.parse(
|
||||
JSON.stringify(flattenTree(items))
|
||||
);
|
||||
const overIndex = clonedItems.findIndex(
|
||||
({ id }) => id === over.id
|
||||
);
|
||||
const activeIndex = clonedItems.findIndex(
|
||||
({ id }) => id === active.id
|
||||
);
|
||||
const activeTreeItem = clonedItems[activeIndex];
|
||||
|
||||
clonedItems[activeIndex] = {
|
||||
...activeTreeItem,
|
||||
depth,
|
||||
parentId,
|
||||
};
|
||||
|
||||
const sortedItems = arrayMove(
|
||||
clonedItems,
|
||||
activeIndex,
|
||||
overIndex
|
||||
);
|
||||
const newItems = buildTree(sortedItems);
|
||||
|
||||
await savePageTreeData(newItems);
|
||||
}
|
||||
},
|
||||
[items, projected, resetState, savePageTreeData]
|
||||
);
|
||||
|
||||
const handleDragCancel = useCallback(() => {
|
||||
resetState();
|
||||
}, [resetState]);
|
||||
|
||||
const handleRemove = useCallback(
|
||||
async (id: string) => {
|
||||
await savePageTreeData(removeItem(items, id));
|
||||
await services.api.userConfig.removePage(workspace_id, id);
|
||||
//remove page from jwst
|
||||
await services.api.pageTree.removePage(workspace_id, id);
|
||||
if (id === page_id) {
|
||||
navigate(`/${workspace_id}`);
|
||||
}
|
||||
},
|
||||
[items, savePageTreeData, workspace_id]
|
||||
);
|
||||
|
||||
const handleAddPage = useCallback(
|
||||
async (page_id?: string) => {
|
||||
await savePageTreeData([{ id: page_id, children: [] }, ...items]);
|
||||
},
|
||||
[items, savePageTreeData]
|
||||
);
|
||||
|
||||
const handleCollapse = useCallback(
|
||||
async (id: string) => {
|
||||
await savePageTreeData(
|
||||
setProperty(items, id, 'collapsed', value => {
|
||||
return !value;
|
||||
})
|
||||
);
|
||||
},
|
||||
[items, savePageTreeData]
|
||||
);
|
||||
|
||||
return {
|
||||
items,
|
||||
activeId,
|
||||
overId,
|
||||
offsetLeft,
|
||||
flattenedItems,
|
||||
projected,
|
||||
handleAddPage,
|
||||
handleDragStart,
|
||||
handleDragMove,
|
||||
handleDragOver,
|
||||
handleDragEnd,
|
||||
handleDragCancel,
|
||||
handleRemove,
|
||||
handleCollapse,
|
||||
};
|
||||
};
|
||||
|
||||
export const useDndTreeAutoUpdate = () => {
|
||||
const [, set_items] = useAtom(page_tree_atom);
|
||||
const { workspace_id, page_id } = useParams();
|
||||
|
||||
const fetch_page_tree_data = useCallback(async () => {
|
||||
const pages = await services.api.pageTree.getPageTree<TreeItem>(
|
||||
workspace_id
|
||||
);
|
||||
set_items(pages);
|
||||
}, [set_items, workspace_id]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch_page_tree_data();
|
||||
}, [fetch_page_tree_data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!page_id) return () => {};
|
||||
let unobserve: () => void;
|
||||
const auto_update_page_tree = async () => {
|
||||
unobserve = await services.api.pageTree.observe(
|
||||
{ workspace: workspace_id, page: page_id },
|
||||
() => {
|
||||
fetch_page_tree_data();
|
||||
}
|
||||
);
|
||||
};
|
||||
auto_update_page_tree();
|
||||
|
||||
return () => {
|
||||
unobserve?.();
|
||||
};
|
||||
}, [fetch_page_tree_data, page_id, workspace_id]);
|
||||
};
|
||||
@@ -0,0 +1,214 @@
|
||||
import { arrayMove } from '@dnd-kit/sortable';
|
||||
|
||||
import type { FlattenedItem, TreeItem, TreeItems } from './types';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
export const iOS = /iPad|iPhone|iPod/.test(navigator.platform);
|
||||
|
||||
function getDragDepth(offset: number, indentationWidth: number) {
|
||||
return Math.round(offset / indentationWidth);
|
||||
}
|
||||
|
||||
export function getProjection(
|
||||
items: FlattenedItem[],
|
||||
activeId: string,
|
||||
overId: string,
|
||||
dragOffset: number,
|
||||
indentationWidth: number
|
||||
) {
|
||||
const overItemIndex = items.findIndex(({ id }) => id === overId);
|
||||
const activeItemIndex = items.findIndex(({ id }) => id === activeId);
|
||||
const activeItem = items[activeItemIndex];
|
||||
const newItems = arrayMove(items, activeItemIndex, overItemIndex);
|
||||
const previousItem = newItems[overItemIndex - 1];
|
||||
const nextItem = newItems[overItemIndex + 1];
|
||||
const dragDepth = getDragDepth(dragOffset, indentationWidth);
|
||||
const projectedDepth = activeItem.depth + dragDepth;
|
||||
const maxDepth = getMaxDepth({
|
||||
previousItem,
|
||||
});
|
||||
const minDepth = getMinDepth({ nextItem });
|
||||
let depth = projectedDepth;
|
||||
|
||||
if (projectedDepth >= maxDepth) {
|
||||
depth = maxDepth;
|
||||
} else if (projectedDepth < minDepth) {
|
||||
depth = minDepth;
|
||||
}
|
||||
|
||||
return { depth, maxDepth, minDepth, parentId: getParentId() };
|
||||
|
||||
function getParentId() {
|
||||
if (depth === 0 || !previousItem) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (depth === previousItem.depth) {
|
||||
return previousItem.parentId;
|
||||
}
|
||||
|
||||
if (depth > previousItem.depth) {
|
||||
return previousItem.id;
|
||||
}
|
||||
|
||||
const newParent = newItems
|
||||
.slice(0, overItemIndex)
|
||||
.reverse()
|
||||
.find(item => item.depth === depth)?.parentId;
|
||||
|
||||
return newParent ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
function getMaxDepth({ previousItem }: { previousItem: FlattenedItem }) {
|
||||
if (previousItem) {
|
||||
return previousItem.depth + 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function getMinDepth({ nextItem }: { nextItem: FlattenedItem }) {
|
||||
if (nextItem) {
|
||||
return nextItem.depth;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function flatten(
|
||||
items: TreeItems,
|
||||
parentId: string | null = null,
|
||||
depth = 0
|
||||
): FlattenedItem[] {
|
||||
return items.reduce<FlattenedItem[]>((acc, item, index) => {
|
||||
return [
|
||||
...acc,
|
||||
{ ...item, parentId, depth, index },
|
||||
...flatten(item.children, item.id, depth + 1),
|
||||
];
|
||||
}, []);
|
||||
}
|
||||
|
||||
export function flattenTree(items: TreeItems): FlattenedItem[] {
|
||||
return flatten(items);
|
||||
}
|
||||
|
||||
export function buildTree(flattenedItems: FlattenedItem[]): TreeItems {
|
||||
const root: TreeItem = { id: 'root', children: [] };
|
||||
const nodes: Record<string, TreeItem> = { [root.id]: root };
|
||||
const items = flattenedItems.map(item => ({ ...item, children: [] }));
|
||||
|
||||
for (const item of items) {
|
||||
const { id, children } = item;
|
||||
const parentId = item.parentId ?? root.id;
|
||||
const parent = nodes[parentId] ?? findItem(items, parentId);
|
||||
|
||||
nodes[id] = { id, children };
|
||||
parent.children.push(item);
|
||||
}
|
||||
|
||||
return root.children;
|
||||
}
|
||||
|
||||
export function findItem(items: TreeItem[], itemId: string) {
|
||||
return items.find(({ id }) => id === itemId);
|
||||
}
|
||||
|
||||
export function findItemDeep(
|
||||
items: TreeItems,
|
||||
itemId: string
|
||||
): TreeItem | undefined {
|
||||
for (const item of items) {
|
||||
const { id, children } = item;
|
||||
|
||||
if (id === itemId) {
|
||||
return item;
|
||||
}
|
||||
|
||||
if (children.length) {
|
||||
const child = findItemDeep(children, itemId);
|
||||
|
||||
if (child) {
|
||||
return child;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Recursively remove id objects from items */
|
||||
export function removeItem(items: TreeItems, id: string) {
|
||||
const newItems = [];
|
||||
|
||||
for (const item of items) {
|
||||
if (item.id === id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.children.length) {
|
||||
item.children = removeItem(item.children, id);
|
||||
}
|
||||
|
||||
newItems.push(item);
|
||||
}
|
||||
|
||||
return newItems;
|
||||
}
|
||||
|
||||
/** Recursively modify the attribute value of the id node in the tree, returning a new array */
|
||||
export function setProperty<T extends keyof TreeItem>(
|
||||
items: TreeItems,
|
||||
id: string,
|
||||
property: T,
|
||||
setter: (value: TreeItem[T]) => TreeItem[T]
|
||||
) {
|
||||
for (const item of items) {
|
||||
if (item.id === id) {
|
||||
item[property] = setter(item[property]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.children.length) {
|
||||
item.children = setProperty(item.children, id, property, setter);
|
||||
}
|
||||
}
|
||||
|
||||
return [...items];
|
||||
}
|
||||
|
||||
function countChildren(items: TreeItem[], count = 0): number {
|
||||
return items.reduce((acc, { children }) => {
|
||||
if (children.length) {
|
||||
return countChildren(children, acc + 1);
|
||||
}
|
||||
|
||||
return acc + 1;
|
||||
}, count);
|
||||
}
|
||||
|
||||
export function getChildCount(items: TreeItems, id: string) {
|
||||
if (!id) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const item = findItemDeep(items, id);
|
||||
|
||||
return item ? countChildren(item.children) : 0;
|
||||
}
|
||||
|
||||
export function removeChildrenOf(items: FlattenedItem[], ids: string[]) {
|
||||
const excludeParentIds = [...ids];
|
||||
|
||||
return items.filter(item => {
|
||||
if (item.parentId && excludeParentIds.includes(item.parentId)) {
|
||||
if (item.children.length) {
|
||||
excludeParentIds.push(item.id);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user