mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 05:14:54 +00:00
feat(core): split right sidebar (#5971)
https://github.com/toeverything/AFFiNE/assets/13579374/c846c069-aa32-445d-b59b-b773a9b05ced Now each view has a general container, the yellow area is the general container part, and the green part is the routing specific part. 
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
import type { AffineEditorContainer } from '@blocksuite/presets';
|
||||
|
||||
export type SidebarTabName = 'outline' | 'frame' | 'copilot' | 'journal';
|
||||
|
||||
export interface SidebarTabProps {
|
||||
editor: AffineEditorContainer | null;
|
||||
}
|
||||
|
||||
export interface SidebarTab {
|
||||
name: SidebarTabName;
|
||||
icon: React.ReactNode;
|
||||
Component: React.ComponentType<SidebarTabProps>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { SidebarTab } from './sidebar-tab';
|
||||
import { copilotTab } from './tabs/copilot';
|
||||
import { framePanelTab } from './tabs/frame';
|
||||
import { journalTab } from './tabs/journal';
|
||||
import { outlineTab } from './tabs/outline';
|
||||
|
||||
// the list of all possible tabs in affine.
|
||||
// order matters (determines the order of the tabs)
|
||||
export const sidebarTabs: SidebarTab[] = [
|
||||
journalTab,
|
||||
outlineTab,
|
||||
framePanelTab,
|
||||
copilotTab,
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
export const root = style({
|
||||
display: 'flex',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
import { AiIcon } from '@blocksuite/icons';
|
||||
import { CopilotPanel } from '@blocksuite/presets';
|
||||
import { useCallback, useRef } from 'react';
|
||||
|
||||
import type { SidebarTab, SidebarTabProps } from '../sidebar-tab';
|
||||
import * as styles from './outline.css';
|
||||
|
||||
// A wrapper for CopilotPanel
|
||||
const EditorCopilotPanel = ({ editor }: SidebarTabProps) => {
|
||||
const copilotPanelRef = useRef<CopilotPanel | null>(null);
|
||||
|
||||
const onRefChange = useCallback((container: HTMLDivElement | null) => {
|
||||
if (container) {
|
||||
assertExists(
|
||||
copilotPanelRef.current,
|
||||
'copilot panel should be initialized'
|
||||
);
|
||||
container.append(copilotPanelRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!copilotPanelRef.current) {
|
||||
copilotPanelRef.current = new CopilotPanel();
|
||||
}
|
||||
|
||||
if (editor !== copilotPanelRef.current?.editor) {
|
||||
(copilotPanelRef.current as CopilotPanel).editor = editor;
|
||||
// (copilotPanelRef.current as CopilotPanel).fitPadding = [20, 20, 20, 20];
|
||||
}
|
||||
|
||||
return <div className={styles.root} ref={onRefChange} />;
|
||||
};
|
||||
|
||||
export const copilotTab: SidebarTab = {
|
||||
name: 'copilot',
|
||||
icon: <AiIcon />,
|
||||
Component: EditorCopilotPanel,
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
export const root = style({
|
||||
display: 'flex',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
import { FrameIcon } from '@blocksuite/icons';
|
||||
import { FramePanel } from '@blocksuite/presets';
|
||||
import { useCallback, useRef } from 'react';
|
||||
|
||||
import type { SidebarTab, SidebarTabProps } from '../sidebar-tab';
|
||||
import * as styles from './frame.css';
|
||||
|
||||
// A wrapper for FramePanel
|
||||
const EditorFramePanel = ({ editor }: SidebarTabProps) => {
|
||||
const framePanelRef = useRef<FramePanel | null>(null);
|
||||
|
||||
const onRefChange = useCallback((container: HTMLDivElement | null) => {
|
||||
if (container) {
|
||||
assertExists(framePanelRef.current, 'frame panel should be initialized');
|
||||
container.append(framePanelRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!framePanelRef.current) {
|
||||
framePanelRef.current = new FramePanel();
|
||||
}
|
||||
|
||||
if (editor !== framePanelRef.current?.editor) {
|
||||
(framePanelRef.current as FramePanel).editor = editor;
|
||||
(framePanelRef.current as FramePanel).fitPadding = [20, 20, 20, 20];
|
||||
}
|
||||
|
||||
return <div className={styles.root} ref={onRefChange} />;
|
||||
};
|
||||
|
||||
export const framePanelTab: SidebarTab = {
|
||||
name: 'frame',
|
||||
icon: <FrameIcon />,
|
||||
Component: EditorFramePanel,
|
||||
};
|
||||
@@ -0,0 +1,227 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
const interactive = style({
|
||||
position: 'relative',
|
||||
cursor: 'pointer',
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
backgroundColor: cssVar('hoverColor'),
|
||||
},
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
opacity: 0,
|
||||
borderRadius: 'inherit',
|
||||
boxShadow: `0 0 0 3px ${cssVar('primaryColor')}`,
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
borderRadius: 'inherit',
|
||||
boxShadow: `0 0 0 0px ${cssVar('primaryColor')}`,
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
'&:focus-visible::before': {
|
||||
opacity: 0.2,
|
||||
},
|
||||
'&:focus-visible::after': {
|
||||
boxShadow: `0 0 0 1px ${cssVar('primaryColor')}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
export const calendar = style({
|
||||
padding: '16px',
|
||||
});
|
||||
export const journalPanel = style({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'stretch',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
export const dailyCount = style({
|
||||
height: 0,
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
});
|
||||
export const dailyCountHeader = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0 16px',
|
||||
gap: 8,
|
||||
});
|
||||
export const dailyCountNav = style([
|
||||
interactive,
|
||||
{
|
||||
height: 28,
|
||||
width: 0,
|
||||
flex: 1,
|
||||
fontWeight: 500,
|
||||
fontSize: 14,
|
||||
padding: '4px 8px',
|
||||
whiteSpace: 'nowrap',
|
||||
borderRadius: 8,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: cssVar('textSecondaryColor'),
|
||||
transition: 'all .3s',
|
||||
selectors: {
|
||||
'&[aria-selected="true"]': {
|
||||
backgroundColor: cssVar('backgroundTertiaryColor'),
|
||||
color: cssVar('textPrimaryColor'),
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
export const dailyCountContainer = style({
|
||||
height: 0,
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
width: `calc(var(--item-count) * 100%)`,
|
||||
transition: 'transform .15s ease',
|
||||
transform:
|
||||
'translateX(calc(var(--active-index) * 100% / var(--item-count) * -1))',
|
||||
});
|
||||
export const dailyCountItem = style({
|
||||
width: 'calc(100% / var(--item-count))',
|
||||
height: '100%',
|
||||
});
|
||||
export const dailyCountContent = style({
|
||||
padding: '8px 16px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
});
|
||||
export const dailyCountEmpty = style({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
maxHeight: 220,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
lineHeight: '24px',
|
||||
fontSize: 15,
|
||||
color: cssVar('textSecondaryColor'),
|
||||
textAlign: 'center',
|
||||
padding: '0 70px',
|
||||
fontWeight: 400,
|
||||
});
|
||||
|
||||
// page item
|
||||
export const pageItem = style([
|
||||
interactive,
|
||||
{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
borderRadius: 4,
|
||||
padding: '0 4px',
|
||||
gap: 8,
|
||||
height: 30,
|
||||
selectors: {
|
||||
'&[aria-selected="true"]': {
|
||||
backgroundColor: cssVar('hoverColor'),
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
export const pageItemIcon = style({
|
||||
width: 20,
|
||||
height: 20,
|
||||
color: cssVar('iconColor'),
|
||||
});
|
||||
export const pageItemLabel = style({
|
||||
width: 0,
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
fontWeight: 500,
|
||||
fontSize: cssVar('fontSm'),
|
||||
color: cssVar('textPrimaryColor'),
|
||||
textAlign: 'left',
|
||||
selectors: {
|
||||
'[aria-selected="true"] &': {
|
||||
// TODO: wait for design
|
||||
color: cssVar('primaryColor'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// conflict
|
||||
export const journalConflictBlock = style({
|
||||
padding: '0 16px 16px 16px',
|
||||
});
|
||||
export const journalConflictWrapper = style({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(170px, 1fr))',
|
||||
rowGap: 4,
|
||||
columnGap: 8,
|
||||
});
|
||||
export const journalConflictMoreTrigger = style([
|
||||
interactive,
|
||||
{
|
||||
color: cssVar('textSecondaryColor'),
|
||||
height: 30,
|
||||
borderRadius: 4,
|
||||
padding: '0px 8px',
|
||||
fontSize: cssVar('fontSm'),
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
},
|
||||
]);
|
||||
|
||||
// customize date-picker cell
|
||||
export const journalDateCell = style([
|
||||
interactive,
|
||||
{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: 8,
|
||||
fontSize: cssVar('fontSm'),
|
||||
color: cssVar('textPrimaryColor'),
|
||||
fontWeight: 400,
|
||||
position: 'relative',
|
||||
|
||||
selectors: {
|
||||
'&[data-is-today="true"]': {
|
||||
fontWeight: 600,
|
||||
color: cssVar('brandColor'),
|
||||
},
|
||||
'&[data-not-current-month="true"]': {
|
||||
color: cssVar('black10'),
|
||||
},
|
||||
'&[data-selected="true"]': {
|
||||
backgroundColor: cssVar('brandColor'),
|
||||
fontWeight: 500,
|
||||
color: cssVar('pureWhite'),
|
||||
},
|
||||
'&[data-is-journal="false"][data-selected="true"]': {
|
||||
backgroundColor: 'transparent',
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
fontWeight: 500,
|
||||
border: `1px solid ${cssVar('primaryColor')}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
export const journalDateCellDot = style({
|
||||
width: 4,
|
||||
height: 4,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: cssVar('primaryColor'),
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
});
|
||||
@@ -0,0 +1,398 @@
|
||||
import {
|
||||
type DateCell,
|
||||
DatePicker,
|
||||
IconButton,
|
||||
Menu,
|
||||
Scrollable,
|
||||
} from '@affine/component';
|
||||
import { MoveToTrash } from '@affine/core/components/page-list';
|
||||
import { useTrashModalHelper } from '@affine/core/hooks/affine/use-trash-modal-helper';
|
||||
import {
|
||||
useJournalHelper,
|
||||
useJournalInfoHelper,
|
||||
useJournalRouteHelper,
|
||||
} from '@affine/core/hooks/use-journal';
|
||||
import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import {
|
||||
EdgelessIcon,
|
||||
MoreHorizontalIcon,
|
||||
PageIcon,
|
||||
TodayIcon,
|
||||
} from '@blocksuite/icons';
|
||||
import type { PageRecord } from '@toeverything/infra';
|
||||
import {
|
||||
Doc,
|
||||
PageRecordList,
|
||||
useLiveData,
|
||||
Workspace,
|
||||
} from '@toeverything/infra';
|
||||
import { useService } from '@toeverything/infra/di';
|
||||
import { assignInlineVars } from '@vanilla-extract/dynamic';
|
||||
import clsx from 'clsx';
|
||||
import dayjs from 'dayjs';
|
||||
import type { HTMLAttributes, PropsWithChildren, ReactNode } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { SidebarTab } from '../sidebar-tab';
|
||||
import * as styles from './journal.css';
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
const CountDisplay = ({
|
||||
count,
|
||||
max = 99,
|
||||
...attrs
|
||||
}: { count: number; max?: number } & HTMLAttributes<HTMLSpanElement>) => {
|
||||
return <span {...attrs}>{count > max ? `${max}+` : count}</span>;
|
||||
};
|
||||
interface PageItemProps extends HTMLAttributes<HTMLDivElement> {
|
||||
pageRecord: PageRecord;
|
||||
right?: ReactNode;
|
||||
}
|
||||
const PageItem = ({
|
||||
pageRecord,
|
||||
right,
|
||||
className,
|
||||
...attrs
|
||||
}: PageItemProps) => {
|
||||
const title = useLiveData(pageRecord.title);
|
||||
const mode = useLiveData(pageRecord.mode);
|
||||
const workspace = useService(Workspace);
|
||||
const { isJournal } = useJournalInfoHelper(
|
||||
workspace.blockSuiteWorkspace,
|
||||
pageRecord.id
|
||||
);
|
||||
|
||||
const Icon = isJournal
|
||||
? TodayIcon
|
||||
: mode === 'edgeless'
|
||||
? EdgelessIcon
|
||||
: PageIcon;
|
||||
return (
|
||||
<div
|
||||
aria-label={title}
|
||||
className={clsx(className, styles.pageItem)}
|
||||
{...attrs}
|
||||
>
|
||||
<div className={styles.pageItemIcon}>
|
||||
<Icon width={20} height={20} />
|
||||
</div>
|
||||
<span className={styles.pageItemLabel}>{title}</span>
|
||||
{right}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type NavItemName = 'createdToday' | 'updatedToday';
|
||||
interface NavItem {
|
||||
name: NavItemName;
|
||||
label: string;
|
||||
count: number;
|
||||
}
|
||||
interface JournalBlockProps {
|
||||
date: dayjs.Dayjs;
|
||||
}
|
||||
|
||||
const EditorJournalPanel = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
const doc = useService(Doc);
|
||||
const workspace = useService(Workspace);
|
||||
const { journalDate, isJournal } = useJournalInfoHelper(
|
||||
workspace.blockSuiteWorkspace,
|
||||
doc.id
|
||||
);
|
||||
const { openJournal } = useJournalRouteHelper(workspace.blockSuiteWorkspace);
|
||||
const [date, setDate] = useState(dayjs().format('YYYY-MM-DD'));
|
||||
|
||||
useEffect(() => {
|
||||
journalDate && setDate(journalDate.format('YYYY-MM-DD'));
|
||||
}, [journalDate]);
|
||||
|
||||
const onDateSelect = useCallback(
|
||||
(date: string) => {
|
||||
if (journalDate && dayjs(date).isSame(dayjs(journalDate))) return;
|
||||
openJournal(date);
|
||||
},
|
||||
[journalDate, openJournal]
|
||||
);
|
||||
|
||||
const customDayRenderer = useCallback(
|
||||
(cell: DateCell) => {
|
||||
// TODO: add a dot to indicate journal
|
||||
// has performance issue for now, better to calculate it in advance
|
||||
// const hasJournal = !!getJournalsByDate(cell.date.format('YYYY-MM-DD'))?.length;
|
||||
const hasJournal = false;
|
||||
return (
|
||||
<button
|
||||
className={styles.journalDateCell}
|
||||
data-is-date-cell
|
||||
tabIndex={cell.focused ? 0 : -1}
|
||||
data-is-today={cell.isToday}
|
||||
data-not-current-month={cell.notCurrentMonth}
|
||||
data-selected={cell.selected}
|
||||
data-is-journal={isJournal}
|
||||
data-has-journal={hasJournal}
|
||||
>
|
||||
{cell.label}
|
||||
{hasJournal && !cell.selected ? (
|
||||
<div className={styles.journalDateCellDot} />
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
[isJournal]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.journalPanel} data-is-journal={isJournal}>
|
||||
<div className={styles.calendar}>
|
||||
<DatePicker
|
||||
weekDays={t['com.affine.calendar-date-picker.week-days']()}
|
||||
monthNames={t['com.affine.calendar-date-picker.month-names']()}
|
||||
todayLabel={t['com.affine.calendar-date-picker.today']()}
|
||||
customDayRenderer={customDayRenderer}
|
||||
value={date}
|
||||
onChange={onDateSelect}
|
||||
/>
|
||||
</div>
|
||||
<JournalConflictBlock date={dayjs(date)} />
|
||||
<JournalDailyCountBlock date={dayjs(date)} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const sortPagesByDate = (
|
||||
pages: PageRecord[],
|
||||
field: 'updatedDate' | 'createDate',
|
||||
order: 'asc' | 'desc' = 'desc'
|
||||
) => {
|
||||
return [...pages].sort((a, b) => {
|
||||
return (
|
||||
(order === 'asc' ? 1 : -1) *
|
||||
dayjs(b.meta.value[field]).diff(dayjs(a.meta.value[field]))
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const DailyCountEmptyFallback = ({ name }: { name: NavItemName }) => {
|
||||
const t = useAFFiNEI18N();
|
||||
|
||||
return (
|
||||
<div className={styles.dailyCountEmpty}>
|
||||
{name === 'createdToday'
|
||||
? t['com.affine.journal.daily-count-created-empty-tips']()
|
||||
: t['com.affine.journal.daily-count-updated-empty-tips']()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const JournalDailyCountBlock = ({ date }: JournalBlockProps) => {
|
||||
const workspace = useService(Workspace);
|
||||
const nodeRef = useRef<HTMLDivElement>(null);
|
||||
const t = useAFFiNEI18N();
|
||||
const [activeItem, setActiveItem] = useState<NavItemName>('createdToday');
|
||||
const pageRecordList = useService(PageRecordList);
|
||||
const pageRecords = useLiveData(pageRecordList.records);
|
||||
|
||||
const navigateHelper = useNavigateHelper();
|
||||
|
||||
const getTodaysPages = useCallback(
|
||||
(field: 'createDate' | 'updatedDate') => {
|
||||
return sortPagesByDate(
|
||||
pageRecords.filter(pageRecord => {
|
||||
if (pageRecord.meta.value.trash) return false;
|
||||
return (
|
||||
pageRecord.meta.value[field] &&
|
||||
dayjs(pageRecord.meta.value[field]).isSame(date, 'day')
|
||||
);
|
||||
}),
|
||||
field
|
||||
);
|
||||
},
|
||||
[date, pageRecords]
|
||||
);
|
||||
|
||||
const createdToday = useMemo(
|
||||
() => getTodaysPages('createDate'),
|
||||
[getTodaysPages]
|
||||
);
|
||||
const updatedToday = useMemo(
|
||||
() => getTodaysPages('updatedDate'),
|
||||
[getTodaysPages]
|
||||
);
|
||||
|
||||
const headerItems = useMemo<NavItem[]>(
|
||||
() => [
|
||||
{
|
||||
name: 'createdToday',
|
||||
label: t['com.affine.journal.created-today'](),
|
||||
count: createdToday.length,
|
||||
},
|
||||
{
|
||||
name: 'updatedToday',
|
||||
label: t['com.affine.journal.updated-today'](),
|
||||
count: updatedToday.length,
|
||||
},
|
||||
],
|
||||
[createdToday.length, t, updatedToday.length]
|
||||
);
|
||||
|
||||
const activeIndex = headerItems.findIndex(({ name }) => name === activeItem);
|
||||
|
||||
const vars = assignInlineVars({
|
||||
'--active-index': String(activeIndex),
|
||||
'--item-count': String(headerItems.length),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.dailyCount} style={vars}>
|
||||
<header className={styles.dailyCountHeader}>
|
||||
{headerItems.map(({ label, count, name }, index) => {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setActiveItem(name)}
|
||||
aria-selected={activeItem === name}
|
||||
className={styles.dailyCountNav}
|
||||
key={index}
|
||||
>
|
||||
{label}
|
||||
|
||||
<CountDisplay count={count} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</header>
|
||||
|
||||
<main className={styles.dailyCountContainer} data-active={activeItem}>
|
||||
{headerItems.map(({ name }) => {
|
||||
const renderList =
|
||||
name === 'createdToday' ? createdToday : updatedToday;
|
||||
if (renderList.length === 0)
|
||||
return (
|
||||
<div key={name} className={styles.dailyCountItem}>
|
||||
<DailyCountEmptyFallback name={name} />
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<Scrollable.Root key={name} className={styles.dailyCountItem}>
|
||||
<Scrollable.Scrollbar />
|
||||
<Scrollable.Viewport>
|
||||
<div className={styles.dailyCountContent} ref={nodeRef}>
|
||||
{renderList.map((pageRecord, index) => (
|
||||
<PageItem
|
||||
onClick={() =>
|
||||
navigateHelper.openPage(workspace.id, pageRecord.id)
|
||||
}
|
||||
tabIndex={name === activeItem ? 0 : -1}
|
||||
key={index}
|
||||
pageRecord={pageRecord}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Scrollable.Viewport>
|
||||
</Scrollable.Root>
|
||||
);
|
||||
})}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MAX_CONFLICT_COUNT = 5;
|
||||
interface ConflictListProps
|
||||
extends PropsWithChildren,
|
||||
HTMLAttributes<HTMLDivElement> {
|
||||
pageRecords: PageRecord[];
|
||||
}
|
||||
const ConflictList = ({
|
||||
pageRecords,
|
||||
children,
|
||||
className,
|
||||
...attrs
|
||||
}: ConflictListProps) => {
|
||||
const navigateHelper = useNavigateHelper();
|
||||
const workspace = useService(Workspace);
|
||||
const currentDoc = useService(Doc);
|
||||
const { setTrashModal } = useTrashModalHelper(workspace.blockSuiteWorkspace);
|
||||
|
||||
const handleOpenTrashModal = useCallback(
|
||||
(pageRecord: PageRecord) => {
|
||||
setTrashModal({
|
||||
open: true,
|
||||
pageIds: [pageRecord.id],
|
||||
pageTitles: [pageRecord.meta.value.title],
|
||||
});
|
||||
},
|
||||
[setTrashModal]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={clsx(styles.journalConflictWrapper, className)} {...attrs}>
|
||||
{pageRecords.map(pageRecord => {
|
||||
const isCurrent = pageRecord.id === currentDoc.id;
|
||||
return (
|
||||
<PageItem
|
||||
aria-selected={isCurrent}
|
||||
pageRecord={pageRecord}
|
||||
key={pageRecord.id}
|
||||
right={
|
||||
<Menu
|
||||
items={
|
||||
<MoveToTrash
|
||||
onSelect={() => handleOpenTrashModal(pageRecord)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<IconButton type="plain">
|
||||
<MoreHorizontalIcon />
|
||||
</IconButton>
|
||||
</Menu>
|
||||
}
|
||||
onClick={() => navigateHelper.openPage(workspace.id, pageRecord.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const JournalConflictBlock = ({ date }: JournalBlockProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const workspace = useService(Workspace);
|
||||
const pageRecordList = useService(PageRecordList);
|
||||
const journalHelper = useJournalHelper(workspace.blockSuiteWorkspace);
|
||||
const docs = journalHelper.getJournalsByDate(date.format('YYYY-MM-DD'));
|
||||
const pageRecords = useLiveData(pageRecordList.records).filter(v => {
|
||||
return docs.some(doc => doc.id === v.id);
|
||||
});
|
||||
|
||||
if (docs.length <= 1) return null;
|
||||
|
||||
return (
|
||||
<ConflictList
|
||||
className={styles.journalConflictBlock}
|
||||
pageRecords={pageRecords.slice(0, MAX_CONFLICT_COUNT)}
|
||||
>
|
||||
{docs.length > MAX_CONFLICT_COUNT ? (
|
||||
<Menu
|
||||
items={
|
||||
<ConflictList pageRecords={pageRecords.slice(MAX_CONFLICT_COUNT)} />
|
||||
}
|
||||
>
|
||||
<div className={styles.journalConflictMoreTrigger}>
|
||||
{t['com.affine.journal.conflict-show-more']({
|
||||
count: (pageRecords.length - MAX_CONFLICT_COUNT).toFixed(0),
|
||||
})}
|
||||
</div>
|
||||
</Menu>
|
||||
) : null}
|
||||
</ConflictList>
|
||||
);
|
||||
};
|
||||
|
||||
export const journalTab: SidebarTab = {
|
||||
name: 'journal',
|
||||
icon: <TodayIcon />,
|
||||
Component: EditorJournalPanel,
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
export const root = style({
|
||||
display: 'flex',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
import { TocIcon } from '@blocksuite/icons';
|
||||
import { OutlinePanel } from '@blocksuite/presets';
|
||||
import { useCallback, useRef } from 'react';
|
||||
|
||||
import type { SidebarTab, SidebarTabProps } from '../sidebar-tab';
|
||||
import * as styles from './outline.css';
|
||||
|
||||
// A wrapper for TOCNotesPanel
|
||||
const EditorOutline = ({ editor }: SidebarTabProps) => {
|
||||
const outlinePanelRef = useRef<OutlinePanel | null>(null);
|
||||
|
||||
const onRefChange = useCallback((container: HTMLDivElement | null) => {
|
||||
if (container) {
|
||||
assertExists(outlinePanelRef.current, 'toc panel should be initialized');
|
||||
container.append(outlinePanelRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!outlinePanelRef.current) {
|
||||
outlinePanelRef.current = new OutlinePanel();
|
||||
}
|
||||
|
||||
if (editor !== outlinePanelRef.current?.editor) {
|
||||
(outlinePanelRef.current as OutlinePanel).editor = editor;
|
||||
(outlinePanelRef.current as OutlinePanel).fitPadding = [20, 20, 20, 20];
|
||||
}
|
||||
|
||||
return <div className={styles.root} ref={onRefChange} />;
|
||||
};
|
||||
|
||||
export const outlineTab: SidebarTab = {
|
||||
name: 'outline',
|
||||
icon: <TocIcon />,
|
||||
Component: EditorOutline,
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export type { SidebarTabName } from './entities/sidebar-tab';
|
||||
export { sidebarTabs } from './entities/sidebar-tabs';
|
||||
export { MultiTabSidebarBody } from './view/body';
|
||||
export { MultiTabSidebarHeaderSwitcher } from './view/header-switcher';
|
||||
@@ -0,0 +1,13 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
export const root = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
minWidth: '320px',
|
||||
overflow: 'hidden',
|
||||
alignItems: 'center',
|
||||
borderTop: `1px solid ${cssVar('borderColor')}`,
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { PropsWithChildren } from 'react';
|
||||
|
||||
import type { SidebarTab, SidebarTabProps } from '../entities/sidebar-tab';
|
||||
import * as styles from './body.css';
|
||||
|
||||
export const MultiTabSidebarBody = (
|
||||
props: PropsWithChildren<SidebarTabProps & { tab?: SidebarTab | null }>
|
||||
) => {
|
||||
const Component = props.tab?.Component;
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
{props.children}
|
||||
{Component ? <Component {...props} /> : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { createVar, style } from '@vanilla-extract/css';
|
||||
export const activeIdx = createVar();
|
||||
export const switchRootWrapper = style({
|
||||
height: '52px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
});
|
||||
export const switchRoot = style({
|
||||
vars: {
|
||||
[activeIdx]: '0',
|
||||
},
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
height: '32px',
|
||||
borderRadius: '12px',
|
||||
padding: '4px',
|
||||
position: 'relative',
|
||||
background: cssVar('backgroundSecondaryColor'),
|
||||
'::after': {
|
||||
content: '""',
|
||||
display: 'block',
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
background: cssVar('backgroundPrimaryColor'),
|
||||
boxShadow: cssVar('shadow1'),
|
||||
borderRadius: '8px',
|
||||
position: 'absolute',
|
||||
transform: `translateX(calc(${activeIdx} * 32px))`,
|
||||
transition: 'all .15s',
|
||||
},
|
||||
});
|
||||
export const button = style({
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
borderRadius: '8px',
|
||||
color: cssVar('iconColor'),
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
selectors: {
|
||||
'&[data-active=true]': {
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { IconButton } from '@affine/component';
|
||||
import { useJournalInfoHelper } from '@affine/core/hooks/use-journal';
|
||||
import { useWorkspaceEnabledFeatures } from '@affine/core/hooks/use-workspace-features';
|
||||
import { FeatureType } from '@affine/graphql';
|
||||
import { Doc, useService, Workspace } from '@toeverything/infra';
|
||||
import { assignInlineVars } from '@vanilla-extract/dynamic';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import type { SidebarTab, SidebarTabName } from '../entities/sidebar-tab';
|
||||
import * as styles from './header-switcher.css';
|
||||
|
||||
export interface MultiTabSidebarHeaderSwitcherProps {
|
||||
tabs: SidebarTab[];
|
||||
activeTabName: SidebarTabName | null;
|
||||
setActiveTabName: (ext: SidebarTabName) => void;
|
||||
}
|
||||
|
||||
// provide a switcher for active extensions
|
||||
// will be used in global top header (MacOS) or sidebar (Windows)
|
||||
export const MultiTabSidebarHeaderSwitcher = ({
|
||||
tabs,
|
||||
activeTabName,
|
||||
setActiveTabName,
|
||||
}: MultiTabSidebarHeaderSwitcherProps) => {
|
||||
const workspace = useService(Workspace);
|
||||
const doc = useService(Doc);
|
||||
const copilotEnabled = useWorkspaceEnabledFeatures(workspace.meta).includes(
|
||||
FeatureType.Copilot
|
||||
);
|
||||
|
||||
const { isJournal } = useJournalInfoHelper(
|
||||
workspace.blockSuiteWorkspace,
|
||||
doc.id
|
||||
);
|
||||
|
||||
const exts = useMemo(
|
||||
() =>
|
||||
tabs.filter(ext => {
|
||||
if (ext.name === 'copilot' && !copilotEnabled) return false;
|
||||
return true;
|
||||
}),
|
||||
[copilotEnabled, tabs]
|
||||
);
|
||||
|
||||
const activeExtension = exts.find(ext => ext.name === activeTabName);
|
||||
|
||||
// if journal is active, set selected to journal
|
||||
useEffect(() => {
|
||||
const journalExtension = tabs.find(ext => ext.name === 'journal');
|
||||
isJournal && journalExtension && setActiveTabName('journal');
|
||||
}, [tabs, isJournal, setActiveTabName]);
|
||||
|
||||
const vars = assignInlineVars({
|
||||
[styles.activeIdx]: String(
|
||||
exts.findIndex(ext => ext.name === activeExtension?.name) ?? 0
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.switchRootWrapper}>
|
||||
<div className={styles.switchRoot} style={vars}>
|
||||
{exts.map(extension => {
|
||||
return (
|
||||
<IconButton
|
||||
onClick={() => setActiveTabName(extension.name)}
|
||||
key={extension.name}
|
||||
data-active={activeExtension === extension}
|
||||
className={styles.button}
|
||||
>
|
||||
{extension.icon}
|
||||
</IconButton>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
export class PageListView {
|
||||
constructor() {}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createIsland } from '../../../utils/island';
|
||||
|
||||
export class RightSidebarView {
|
||||
readonly body = createIsland();
|
||||
readonly header = createIsland();
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { LiveData } from '@toeverything/infra/livedata';
|
||||
|
||||
import type { RightSidebarView } from './right-sidebar-view';
|
||||
|
||||
export class RightSidebar {
|
||||
readonly isOpen = new LiveData(false);
|
||||
readonly views = new LiveData<RightSidebarView[]>([]);
|
||||
readonly front = this.views.map(
|
||||
stack => stack[0] as RightSidebarView | undefined
|
||||
);
|
||||
readonly hasViews = this.views.map(stack => stack.length > 0);
|
||||
|
||||
open() {
|
||||
this.isOpen.next(true);
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.isOpen.next(!this.isOpen.value);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.isOpen.next(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private use `RightSidebarViewIsland` instead
|
||||
*/
|
||||
_append(view: RightSidebarView) {
|
||||
this.views.next([...this.views.value, view]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private use `RightSidebarViewIsland` instead
|
||||
*/
|
||||
_moveToFront(view: RightSidebarView) {
|
||||
if (this.views.value.includes(view)) {
|
||||
this.views.next([view, ...this.views.value.filter(v => v !== view)]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private use `RightSidebarViewIsland` instead
|
||||
*/
|
||||
_remove(view: RightSidebarView) {
|
||||
this.views.next(this.views.value.filter(v => v !== view));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { RightSidebar } from './entities/right-sidebar';
|
||||
export { RightSidebarContainer } from './view/container';
|
||||
export { RightSidebarViewIsland } from './view/view-island';
|
||||
@@ -0,0 +1,40 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const sidebarContainerInner = style({
|
||||
display: 'flex',
|
||||
background: cssVar('backgroundPrimaryColor'),
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
export const sidebarContainer = style({
|
||||
display: 'flex',
|
||||
flexShrink: 0,
|
||||
height: '100%',
|
||||
selectors: {
|
||||
[`&[data-client-border=true]`]: {
|
||||
paddingLeft: 9,
|
||||
},
|
||||
[`&[data-client-border=false]`]: {
|
||||
borderLeft: `1px solid ${cssVar('borderColor')}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const sidebarBodyTarget = style({
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
minWidth: '320px',
|
||||
overflow: 'hidden',
|
||||
selectors: {
|
||||
[`&[data-client-border=true]`]: {
|
||||
paddingLeft: 9,
|
||||
},
|
||||
[`&[data-client-border=false]`]: {
|
||||
borderLeft: `1px solid ${cssVar('borderColor')}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { ResizePanel } from '@affine/component/resize-panel';
|
||||
import { appSettingAtom } from '@toeverything/infra/atom';
|
||||
import { useService } from '@toeverything/infra/di';
|
||||
import { useLiveData } from '@toeverything/infra/livedata';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { RightSidebar } from '../entities/right-sidebar';
|
||||
import * as styles from './container.css';
|
||||
import { Header } from './header';
|
||||
|
||||
const MIN_SIDEBAR_WIDTH = 320;
|
||||
const MAX_SIDEBAR_WIDTH = 800;
|
||||
|
||||
export const RightSidebarContainer = () => {
|
||||
const { clientBorder } = useAtomValue(appSettingAtom);
|
||||
const [width, setWidth] = useState(300);
|
||||
const [resizing, setResizing] = useState(false);
|
||||
const rightSidebar = useService(RightSidebar);
|
||||
|
||||
const frontView = useLiveData(rightSidebar.front);
|
||||
const open = useLiveData(rightSidebar.isOpen) && frontView !== undefined;
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (open) {
|
||||
rightSidebar.open();
|
||||
} else {
|
||||
rightSidebar.close();
|
||||
}
|
||||
},
|
||||
[rightSidebar]
|
||||
);
|
||||
|
||||
const handleToggleOpen = useCallback(() => {
|
||||
rightSidebar.toggle();
|
||||
}, [rightSidebar]);
|
||||
|
||||
return (
|
||||
<ResizePanel
|
||||
resizeHandlePos="left"
|
||||
resizeHandleOffset={clientBorder ? 4 : 0}
|
||||
width={width}
|
||||
resizing={resizing}
|
||||
onResizing={setResizing}
|
||||
className={styles.sidebarContainer}
|
||||
data-client-border={clientBorder && open}
|
||||
open={open}
|
||||
onOpen={handleOpenChange}
|
||||
onWidthChange={setWidth}
|
||||
minWidth={MIN_SIDEBAR_WIDTH}
|
||||
maxWidth={MAX_SIDEBAR_WIDTH}
|
||||
>
|
||||
{frontView && (
|
||||
<div className={styles.sidebarContainerInner}>
|
||||
<Header
|
||||
floating={false}
|
||||
onToggle={handleToggleOpen}
|
||||
view={frontView}
|
||||
/>
|
||||
<frontView.body.Target
|
||||
className={styles.sidebarBodyTarget}
|
||||
></frontView.body.Target>
|
||||
</div>
|
||||
)}
|
||||
</ResizePanel>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const header = style({
|
||||
display: 'flex',
|
||||
height: '52px',
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
padding: '0 16px',
|
||||
gap: '12px',
|
||||
background: cssVar('backgroundPrimaryColor'),
|
||||
selectors: {
|
||||
'&[data-sidebar-floating="false"]': {
|
||||
['WebkitAppRegion' as string]: 'drag',
|
||||
},
|
||||
},
|
||||
'@media': {
|
||||
print: {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const spacer = style({
|
||||
flexGrow: 1,
|
||||
minWidth: 12,
|
||||
});
|
||||
|
||||
export const standaloneExtensionSwitcherWrapper = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
height: '52px',
|
||||
position: 'relative',
|
||||
});
|
||||
|
||||
export const windowsAppControlsContainer = style({
|
||||
display: 'flex',
|
||||
height: '100%',
|
||||
marginRight: '-16px',
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { IconButton } from '@affine/component';
|
||||
import { RightSidebarIcon } from '@blocksuite/icons';
|
||||
|
||||
import { WindowsAppControls } from '../../../components/pure/header/windows-app-controls';
|
||||
import type { RightSidebarView } from '../entities/right-sidebar-view';
|
||||
import * as styles from './header.css';
|
||||
|
||||
export type HeaderProps = {
|
||||
floating: boolean;
|
||||
onToggle?: () => void;
|
||||
view: RightSidebarView;
|
||||
};
|
||||
|
||||
function Container({
|
||||
children,
|
||||
style,
|
||||
className,
|
||||
floating,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
floating?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-testid="header"
|
||||
style={style}
|
||||
className={className}
|
||||
data-sidebar-floating={floating}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ToggleButton = ({ onToggle }: { onToggle?: () => void }) => {
|
||||
return (
|
||||
<IconButton size="large" onClick={onToggle}>
|
||||
<RightSidebarIcon />
|
||||
</IconButton>
|
||||
);
|
||||
};
|
||||
|
||||
const Windows = ({ floating, onToggle, view }: HeaderProps) => {
|
||||
return (
|
||||
<Container className={styles.header} floating={floating}>
|
||||
<view.header.Target></view.header.Target>
|
||||
<div className={styles.spacer} />
|
||||
<ToggleButton onToggle={onToggle} />
|
||||
<div className={styles.windowsAppControlsContainer}>
|
||||
<WindowsAppControls />
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
const NonWindows = ({ floating, view, onToggle }: HeaderProps) => {
|
||||
return (
|
||||
<Container className={styles.header} floating={floating}>
|
||||
<view.header.Target></view.header.Target>
|
||||
<div className={styles.spacer} />
|
||||
<ToggleButton onToggle={onToggle} />
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export const Header =
|
||||
environment.isDesktop && environment.isWindows ? Windows : NonWindows;
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useService } from '@toeverything/infra';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import { RightSidebar } from '../entities/right-sidebar';
|
||||
import { RightSidebarView } from '../entities/right-sidebar-view';
|
||||
|
||||
export interface RightSidebarViewProps {
|
||||
body: JSX.Element;
|
||||
header?: JSX.Element | null;
|
||||
name?: string;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export const RightSidebarViewIsland = ({
|
||||
body,
|
||||
header,
|
||||
active,
|
||||
}: RightSidebarViewProps) => {
|
||||
const rightSidebar = useService(RightSidebar);
|
||||
|
||||
const view = useMemo(() => new RightSidebarView(), []);
|
||||
|
||||
useEffect(() => {
|
||||
rightSidebar._append(view);
|
||||
return () => {
|
||||
rightSidebar._remove(view);
|
||||
};
|
||||
}, [rightSidebar, view]);
|
||||
|
||||
useEffect(() => {
|
||||
if (active) {
|
||||
rightSidebar._moveToFront(view);
|
||||
}
|
||||
}, [active, rightSidebar, view]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<view.header.Provider>{header}</view.header.Provider>
|
||||
<view.body.Provider>{body}</view.body.Provider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
LocalStorageGlobalCache,
|
||||
LocalStorageGlobalState,
|
||||
} from './infra-web/storage';
|
||||
import { RightSidebar } from './right-sidebar/entities/right-sidebar';
|
||||
import { Workbench } from './workbench';
|
||||
import {
|
||||
CurrentWorkspaceService,
|
||||
@@ -23,6 +24,7 @@ export function configureBusinessServices(services: ServiceCollection) {
|
||||
services
|
||||
.scope(WorkspaceScope)
|
||||
.add(Workbench)
|
||||
.add(RightSidebar)
|
||||
.add(WorkspacePropertiesAdapter, [Workspace])
|
||||
.add(CollectionService, [Workspace])
|
||||
.add(WorkspaceLegacyProperties, [Workspace]);
|
||||
|
||||
@@ -4,6 +4,8 @@ import { createMemoryHistory } from 'history';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { createIsland } from '../../../utils/island';
|
||||
|
||||
export class View {
|
||||
id = nanoid();
|
||||
|
||||
@@ -19,6 +21,9 @@ export class View {
|
||||
this.history.location
|
||||
);
|
||||
|
||||
header = createIsland();
|
||||
body = createIsland();
|
||||
|
||||
push(path: To) {
|
||||
this.history.push(path);
|
||||
}
|
||||
@@ -1,2 +1,7 @@
|
||||
export * from './view';
|
||||
export * from './workbench';
|
||||
export { View } from './entities/view';
|
||||
export { Workbench } from './entities/workbench';
|
||||
export { useIsActiveView } from './view/use-is-active-view';
|
||||
export { ViewBodyIsland } from './view/view-body-island';
|
||||
export { ViewHeaderIsland } from './view/view-header-island';
|
||||
export { WorkbenchLink } from './view/workbench-link';
|
||||
export { WorkbenchRoot } from './view/workbench-root';
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect } from 'react';
|
||||
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import type { Workbench } from './workbench';
|
||||
import type { Workbench } from '../entities/workbench';
|
||||
|
||||
/**
|
||||
* This hook binds the workbench to the browser router.
|
||||
@@ -3,7 +3,7 @@ import { useEffect } from 'react';
|
||||
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import type { Workbench } from './workbench';
|
||||
import type { Workbench } from '../entities/workbench';
|
||||
|
||||
/**
|
||||
* This hook binds the workbench to the browser router.
|
||||
@@ -1 +0,0 @@
|
||||
export * from './view';
|
||||
@@ -0,0 +1,57 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const root = style({
|
||||
display: 'flex',
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
flexDirection: 'column',
|
||||
minWidth: 0,
|
||||
background: cssVar('backgroundPrimaryColor'),
|
||||
});
|
||||
|
||||
export const header = style({
|
||||
display: 'flex',
|
||||
height: '52px',
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
background: cssVar('backgroundPrimaryColor'),
|
||||
padding: '0 16px',
|
||||
['WebkitAppRegion' as string]: 'drag',
|
||||
'@media': {
|
||||
print: {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const viewBodyContainer = style({
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
export const leftSidebarButton = style({
|
||||
margin: '0 16px 0 0',
|
||||
});
|
||||
|
||||
export const rightSidebarButton = style({
|
||||
margin: '0 0 0 16px',
|
||||
});
|
||||
|
||||
export const viewHeaderContainer = style({
|
||||
display: 'flex',
|
||||
height: '100%',
|
||||
flexGrow: 1,
|
||||
minWidth: 12,
|
||||
});
|
||||
|
||||
export const windowsAppControlsContainer = style({
|
||||
display: 'flex',
|
||||
height: '100%',
|
||||
marginRight: '-16px',
|
||||
paddingLeft: '16px',
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { IconButton } from '@affine/component';
|
||||
import {
|
||||
appSidebarOpenAtom,
|
||||
SidebarSwitch,
|
||||
} from '@affine/component/app-sidebar';
|
||||
import { WindowsAppControls } from '@affine/core/components/pure/header/windows-app-controls';
|
||||
import { RightSidebarIcon } from '@blocksuite/icons';
|
||||
import { useLiveData } from '@toeverything/infra';
|
||||
import { useService } from '@toeverything/infra/di';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { Suspense, useCallback } from 'react';
|
||||
|
||||
import { RightSidebar } from '../../right-sidebar';
|
||||
import * as styles from './route-container.css';
|
||||
import { useView } from './use-view';
|
||||
import { useViewPosition } from './use-view-position';
|
||||
|
||||
export interface Props {
|
||||
route: {
|
||||
Component: React.ComponentType;
|
||||
};
|
||||
}
|
||||
|
||||
const ToggleButton = ({
|
||||
onToggle,
|
||||
className,
|
||||
}: {
|
||||
onToggle?: () => void;
|
||||
className: string;
|
||||
}) => {
|
||||
return (
|
||||
<IconButton size="large" onClick={onToggle} className={className}>
|
||||
<RightSidebarIcon />
|
||||
</IconButton>
|
||||
);
|
||||
};
|
||||
|
||||
export const RouteContainer = ({ route }: Props) => {
|
||||
const view = useView();
|
||||
const viewPosition = useViewPosition();
|
||||
const leftSidebarOpen = useAtomValue(appSidebarOpenAtom);
|
||||
const rightSidebar = useService(RightSidebar);
|
||||
const rightSidebarOpen = useLiveData(rightSidebar.isOpen);
|
||||
const rightSidebarHasViews = useLiveData(rightSidebar.hasViews);
|
||||
const handleToggleRightSidebar = useCallback(() => {
|
||||
rightSidebar.toggle();
|
||||
}, [rightSidebar]);
|
||||
const isWindowsDesktop = environment.isDesktop && environment.isWindows;
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div className={styles.header}>
|
||||
{viewPosition.isFirst && !leftSidebarOpen && (
|
||||
<SidebarSwitch className={styles.leftSidebarButton} />
|
||||
)}
|
||||
<view.header.Target className={styles.viewHeaderContainer} />
|
||||
{viewPosition.isLast && !rightSidebarOpen && rightSidebarHasViews && (
|
||||
<>
|
||||
<ToggleButton
|
||||
className={styles.rightSidebarButton}
|
||||
onToggle={handleToggleRightSidebar}
|
||||
/>
|
||||
{isWindowsDesktop && (
|
||||
<div className={styles.windowsAppControlsContainer}>
|
||||
<WindowsAppControls />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<view.body.Target className={styles.viewBodyContainer} />
|
||||
<Suspense fallback={<>loading</>}>
|
||||
<route.Component />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useService } from '@toeverything/infra';
|
||||
import { useLiveData } from '@toeverything/infra';
|
||||
|
||||
import { Workbench } from '../entities/workbench';
|
||||
import { useView } from './use-view';
|
||||
|
||||
export function useIsActiveView() {
|
||||
const workbench = useService(Workbench);
|
||||
const currentView = useView();
|
||||
const activeView = useLiveData(workbench.activeView);
|
||||
return currentView === activeView;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useService } from '@toeverything/infra/di';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type { View } from '../entities/view';
|
||||
import { Workbench } from '../entities/workbench';
|
||||
import { useView } from './use-view';
|
||||
|
||||
export const useViewPosition = () => {
|
||||
const workbench = useService(Workbench);
|
||||
const view = useView();
|
||||
|
||||
const [position, setPosition] = useState(() =>
|
||||
calcPosition(view, workbench.views.value)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = workbench.views.subscribe(views => {
|
||||
setPosition(calcPosition(view, views));
|
||||
});
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [view, workbench]);
|
||||
|
||||
return position;
|
||||
};
|
||||
|
||||
function calcPosition(view: View, viewList: View[]) {
|
||||
const index = viewList.indexOf(view);
|
||||
return {
|
||||
index: index,
|
||||
isFirst: index === 0,
|
||||
isLast: index === viewList.length - 1,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
import type { View } from '../entities/view';
|
||||
|
||||
export const ViewContext = createContext<View | null>(null);
|
||||
|
||||
export const useView = () => {
|
||||
const view = useContext(ViewContext);
|
||||
if (!view) {
|
||||
throw new Error(
|
||||
'No view found in context. Make sure you are rendering inside a ViewRoot.'
|
||||
);
|
||||
}
|
||||
return view;
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { useView } from './use-view';
|
||||
|
||||
export const ViewBodyIsland = ({ children }: React.PropsWithChildren) => {
|
||||
const view = useView();
|
||||
return <view.body.Provider>{children}</view.body.Provider>;
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { useView } from './use-view';
|
||||
|
||||
export const ViewHeaderIsland = ({ children }: React.PropsWithChildren) => {
|
||||
const view = useView();
|
||||
return <view.header.Provider>{children}</view.header.Provider>;
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useLiveData } from '@toeverything/infra/livedata';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { lazy as reactLazy, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
createMemoryRouter,
|
||||
RouterProvider,
|
||||
@@ -8,10 +8,30 @@ import {
|
||||
} from 'react-router-dom';
|
||||
|
||||
import { viewRoutes } from '../../../router';
|
||||
import type { View } from './view';
|
||||
import type { View } from '../entities/view';
|
||||
import { RouteContainer } from './route-container';
|
||||
import { ViewContext } from './use-view';
|
||||
|
||||
const warpedRoutes = viewRoutes.map(({ path, lazy }) => {
|
||||
const Component = reactLazy(() =>
|
||||
lazy().then(m => ({
|
||||
default: m.Component as React.ComponentType,
|
||||
}))
|
||||
);
|
||||
const route = {
|
||||
Component,
|
||||
};
|
||||
|
||||
return {
|
||||
path,
|
||||
Component: () => {
|
||||
return <RouteContainer route={route} />;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const ViewRoot = ({ view }: { view: View }) => {
|
||||
const viewRouter = useMemo(() => createMemoryRouter(viewRoutes), []);
|
||||
const viewRouter = useMemo(() => createMemoryRouter(warpedRoutes), []);
|
||||
|
||||
const location = useLiveData(view.location);
|
||||
|
||||
@@ -23,16 +43,18 @@ export const ViewRoot = ({ view }: { view: View }) => {
|
||||
|
||||
// https://github.com/remix-run/react-router/issues/7375#issuecomment-975431736
|
||||
return (
|
||||
<UNSAFE_LocationContext.Provider value={null as any}>
|
||||
<UNSAFE_RouteContext.Provider
|
||||
value={{
|
||||
outlet: null,
|
||||
matches: [],
|
||||
isDataRoute: false,
|
||||
}}
|
||||
>
|
||||
<RouterProvider router={viewRouter} />
|
||||
</UNSAFE_RouteContext.Provider>
|
||||
</UNSAFE_LocationContext.Provider>
|
||||
<ViewContext.Provider value={view}>
|
||||
<UNSAFE_LocationContext.Provider value={null as any}>
|
||||
<UNSAFE_RouteContext.Provider
|
||||
value={{
|
||||
outlet: null,
|
||||
matches: [],
|
||||
isDataRoute: false,
|
||||
}}
|
||||
>
|
||||
<RouterProvider router={viewRouter} />
|
||||
</UNSAFE_RouteContext.Provider>
|
||||
</UNSAFE_LocationContext.Provider>
|
||||
</ViewContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useService } from '@toeverything/infra/di';
|
||||
import type { To } from 'history';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { Workbench } from './workbench';
|
||||
import { Workbench } from '../entities/workbench';
|
||||
|
||||
export const WorkbenchLink = ({
|
||||
to,
|
||||
@@ -17,11 +17,15 @@ export const WorkbenchLink = ({
|
||||
(event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
event.preventDefault();
|
||||
// TODO: open this when multi view control is implemented
|
||||
// if (environment.isDesktop && (event.ctrlKey || event.metaKey)) {
|
||||
// workbench.open(to, { at: 'beside' });
|
||||
// } else {
|
||||
workbench.open(to);
|
||||
// }
|
||||
if (
|
||||
(window as any).enableMultiView &&
|
||||
environment.isDesktop &&
|
||||
(event.ctrlKey || event.metaKey)
|
||||
) {
|
||||
workbench.open(to, { at: 'beside' });
|
||||
} else {
|
||||
workbench.open(to);
|
||||
}
|
||||
|
||||
onClick?.(event);
|
||||
},
|
||||
@@ -3,8 +3,17 @@ import { style } from '@vanilla-extract/css';
|
||||
export const workbenchRootContainer = style({
|
||||
display: 'flex',
|
||||
height: '100%',
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
selectors: {
|
||||
[`&[data-client-border="true"]`]: {
|
||||
gap: '8px',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const workbenchViewContainer = style({
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
height: '100%',
|
||||
});
|
||||
@@ -1,17 +1,16 @@
|
||||
import { appSettingAtom } from '@toeverything/infra/atom';
|
||||
import { useService } from '@toeverything/infra/di';
|
||||
import { useLiveData } from '@toeverything/infra/livedata';
|
||||
import { useCallback } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import type { View } from '../entities/view';
|
||||
import { Workbench } from '../entities/workbench';
|
||||
import { useBindWorkbenchToBrowserRouter } from './browser-adapter';
|
||||
import { useBindWorkbenchToDesktopRouter } from './desktop-adapter';
|
||||
import type { View } from './view';
|
||||
import { ViewRoot } from './view/view-root';
|
||||
import { Workbench } from './workbench';
|
||||
import {
|
||||
workbenchRootContainer,
|
||||
workbenchViewContainer,
|
||||
} from './workbench-root.css';
|
||||
import { ViewRoot } from './view-root';
|
||||
import * as styles from './workbench-root.css';
|
||||
|
||||
const useAdapter = environment.isDesktop
|
||||
? useBindWorkbenchToDesktopRouter
|
||||
@@ -30,8 +29,13 @@ export const WorkbenchRoot = () => {
|
||||
|
||||
useAdapter(workbench, basename);
|
||||
|
||||
const { clientBorder } = useAtomValue(appSettingAtom);
|
||||
|
||||
return (
|
||||
<div className={workbenchRootContainer}>
|
||||
<div
|
||||
className={styles.workbenchRootContainer}
|
||||
data-client-border={!!clientBorder}
|
||||
>
|
||||
{views.map((view, index) => (
|
||||
<WorkbenchView key={view.id} view={view} index={index} />
|
||||
))}
|
||||
@@ -46,8 +50,25 @@ const WorkbenchView = ({ view, index }: { view: View; index: number }) => {
|
||||
workbench.active(index);
|
||||
}, [workbench, index]);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (containerRef.current) {
|
||||
const element = containerRef.current;
|
||||
element.addEventListener('mousedown', handleOnFocus, {
|
||||
capture: true,
|
||||
});
|
||||
return () => {
|
||||
element.removeEventListener('mousedown', handleOnFocus, {
|
||||
capture: true,
|
||||
});
|
||||
};
|
||||
}
|
||||
return;
|
||||
}, [handleOnFocus]);
|
||||
|
||||
return (
|
||||
<div className={workbenchViewContainer} onMouseDownCapture={handleOnFocus}>
|
||||
<div className={styles.workbenchViewContainer} ref={containerRef}>
|
||||
<ViewRoot key={view.id} view={view} />
|
||||
</div>
|
||||
);
|
||||
Reference in New Issue
Block a user