mirror of
https://github.com/toeverything/AFFiNE.git
synced 2024-11-24 03:26:31 +03:00
feat(core): resize and reorder split-view (#5994)
This commit is contained in:
parent
7b31363c51
commit
2275eee5b2
@ -11,6 +11,7 @@ export const menuContent = style({
|
|||||||
backgroundColor: cssVar('backgroundOverlayPanelColor'),
|
backgroundColor: cssVar('backgroundOverlayPanelColor'),
|
||||||
boxShadow: cssVar('menuShadow'),
|
boxShadow: cssVar('menuShadow'),
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
|
['WebkitAppRegion' as string]: 'no-drag',
|
||||||
});
|
});
|
||||||
export const menuItem = style({
|
export const menuItem = style({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
@ -20,6 +20,7 @@ export class View {
|
|||||||
}),
|
}),
|
||||||
this.history.location
|
this.history.location
|
||||||
);
|
);
|
||||||
|
size = new LiveData(100);
|
||||||
|
|
||||||
header = createIsland();
|
header = createIsland();
|
||||||
body = createIsland();
|
body = createIsland();
|
||||||
@ -35,4 +36,8 @@ export class View {
|
|||||||
replace(path: To) {
|
replace(path: To) {
|
||||||
this.history.replace(path);
|
this.history.replace(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setSize(size?: number) {
|
||||||
|
this.size.next(size ?? 100);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -89,6 +89,48 @@ export class Workbench {
|
|||||||
return this.views.value[this.indexAt(positionIndex)];
|
return this.views.value[this.indexAt(positionIndex)];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
close(view: View) {
|
||||||
|
if (this.views.value.length === 1) return;
|
||||||
|
const index = this.views.value.indexOf(view);
|
||||||
|
if (index === -1) return;
|
||||||
|
const newViews = [...this.views.value];
|
||||||
|
newViews.splice(index, 1);
|
||||||
|
this.views.next(newViews);
|
||||||
|
}
|
||||||
|
|
||||||
|
closeOthers(view: View) {
|
||||||
|
view.size.next(100);
|
||||||
|
this.views.next([view]);
|
||||||
|
}
|
||||||
|
|
||||||
|
moveView(from: number, to: number) {
|
||||||
|
const views = [...this.views.value];
|
||||||
|
const [removed] = views.splice(from, 1);
|
||||||
|
views.splice(to, 0, removed);
|
||||||
|
this.views.next(views);
|
||||||
|
this.active(to);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* resize specified view and the next view
|
||||||
|
* @param view
|
||||||
|
* @param percent from 0 to 1
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
resize(index: number, percent: number) {
|
||||||
|
const view = this.views.value[index];
|
||||||
|
const nextView = this.views.value[index + 1];
|
||||||
|
if (!nextView) return;
|
||||||
|
|
||||||
|
const totalViewSize = this.views.value.reduce(
|
||||||
|
(sum, v) => sum + v.size.value,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const percentOfTotal = totalViewSize * percent;
|
||||||
|
view.setSize(Number((view.size.value + percentOfTotal).toFixed(4)));
|
||||||
|
nextView.setSize(Number((nextView.size.value - percentOfTotal).toFixed(4)));
|
||||||
|
}
|
||||||
|
|
||||||
private indexAt(positionIndex: WorkbenchPosition): number {
|
private indexAt(positionIndex: WorkbenchPosition): number {
|
||||||
if (positionIndex === 'active') {
|
if (positionIndex === 'active') {
|
||||||
return this.activeViewIndex.value;
|
return this.activeViewIndex.value;
|
||||||
|
@ -0,0 +1,26 @@
|
|||||||
|
import { cssVar } from '@toeverything/theme';
|
||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
export const indicator = style({
|
||||||
|
width: 29,
|
||||||
|
height: 14,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
['WebkitAppRegion' as string]: 'no-drag',
|
||||||
|
color: cssVar('placeholderColor'),
|
||||||
|
|
||||||
|
selectors: {
|
||||||
|
'&:hover, &:active, &[data-active="true"]': {
|
||||||
|
color: cssVar('brandColor'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const indicatorInner = style({
|
||||||
|
width: 15,
|
||||||
|
height: 3,
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: 'currentColor',
|
||||||
|
});
|
@ -0,0 +1,28 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
import { forwardRef, type HTMLAttributes, memo } from 'react';
|
||||||
|
|
||||||
|
import * as styles from './indicator.css';
|
||||||
|
|
||||||
|
export interface SplitViewMenuProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SplitViewMenuIndicator = memo(
|
||||||
|
forwardRef<HTMLDivElement, SplitViewMenuProps>(
|
||||||
|
function SplitViewMenuIndicator(
|
||||||
|
{ className, active, ...attrs }: SplitViewMenuProps,
|
||||||
|
ref
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
data-active={active}
|
||||||
|
className={clsx(className, styles.indicator)}
|
||||||
|
{...attrs}
|
||||||
|
>
|
||||||
|
<div className={styles.indicatorInner} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
@ -0,0 +1,209 @@
|
|||||||
|
import { Menu, MenuIcon, MenuItem, type MenuProps } from '@affine/component';
|
||||||
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
|
import {
|
||||||
|
CloseIcon,
|
||||||
|
ExpandFullIcon,
|
||||||
|
InsertLeftIcon,
|
||||||
|
InsertRightIcon,
|
||||||
|
} from '@blocksuite/icons';
|
||||||
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
|
import { useService } from '@toeverything/infra/di';
|
||||||
|
import { useLiveData } from '@toeverything/infra/livedata';
|
||||||
|
import { assignInlineVars } from '@vanilla-extract/dynamic';
|
||||||
|
import type { SetStateAction } from 'jotai';
|
||||||
|
import {
|
||||||
|
type Dispatch,
|
||||||
|
type HTMLAttributes,
|
||||||
|
memo,
|
||||||
|
type PropsWithChildren,
|
||||||
|
type RefObject,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
import type { View } from '../../entities/view';
|
||||||
|
import { Workbench } from '../../entities/workbench';
|
||||||
|
import { SplitViewMenuIndicator } from './indicator';
|
||||||
|
import * as styles from './split-view.css';
|
||||||
|
|
||||||
|
export interface SplitViewPanelProps
|
||||||
|
extends PropsWithChildren<HTMLAttributes<HTMLDivElement>> {
|
||||||
|
view: View;
|
||||||
|
resizeHandle?: React.ReactNode;
|
||||||
|
setSlots?: Dispatch<
|
||||||
|
SetStateAction<Record<string, RefObject<HTMLDivElement | null>>>
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SplitViewPanel = memo(function SplitViewPanel({
|
||||||
|
children,
|
||||||
|
view,
|
||||||
|
setSlots,
|
||||||
|
}: SplitViewPanelProps) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const size = useLiveData(view.size);
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const workbench = useService(Workbench);
|
||||||
|
const activeView = useLiveData(workbench.activeView);
|
||||||
|
const views = useLiveData(workbench.views);
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
setNodeRef,
|
||||||
|
setActivatorNodeRef,
|
||||||
|
} = useSortable({ id: view.id, attributes: { role: 'group' } });
|
||||||
|
|
||||||
|
const isActive = activeView === view;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (ref.current) {
|
||||||
|
setSlots?.(slots => ({ ...slots, [view.id]: ref }));
|
||||||
|
}
|
||||||
|
}, [setSlots, view.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDragging) {
|
||||||
|
setMenuOpen(false);
|
||||||
|
}
|
||||||
|
}, [isDragging]);
|
||||||
|
|
||||||
|
const style = useMemo(
|
||||||
|
() => ({
|
||||||
|
...assignInlineVars({ '--size': size.toString() }),
|
||||||
|
}),
|
||||||
|
[size]
|
||||||
|
);
|
||||||
|
const dragStyle = useMemo(
|
||||||
|
() => ({
|
||||||
|
transform: `translate3d(${transform?.x ?? 0}px, 0, 0)`,
|
||||||
|
transition,
|
||||||
|
}),
|
||||||
|
[transform, transition]
|
||||||
|
);
|
||||||
|
const menuRootOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
open: menuOpen,
|
||||||
|
onOpenChange: setMenuOpen,
|
||||||
|
}) satisfies MenuProps['rootOptions'],
|
||||||
|
[menuOpen]
|
||||||
|
);
|
||||||
|
const menuContentOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
align: 'center',
|
||||||
|
}) satisfies MenuProps['contentOptions'],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={style}
|
||||||
|
className={styles.splitViewPanel}
|
||||||
|
data-is-dragging={isDragging}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={dragStyle}
|
||||||
|
className={styles.splitViewPanelDrag}
|
||||||
|
{...attributes}
|
||||||
|
>
|
||||||
|
<div className={styles.splitViewPanelContent} ref={ref} />
|
||||||
|
{views.length > 1 ? (
|
||||||
|
<Menu
|
||||||
|
contentOptions={menuContentOptions}
|
||||||
|
items={<SplitViewMenu view={view} />}
|
||||||
|
rootOptions={menuRootOptions}
|
||||||
|
>
|
||||||
|
<SplitViewMenuIndicator
|
||||||
|
ref={setActivatorNodeRef}
|
||||||
|
active={isDragging || isActive}
|
||||||
|
className={styles.menuTrigger}
|
||||||
|
{...listeners}
|
||||||
|
/>
|
||||||
|
</Menu>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface SplitViewMenuProps {
|
||||||
|
view: View;
|
||||||
|
}
|
||||||
|
const SplitViewMenu = ({ view }: SplitViewMenuProps) => {
|
||||||
|
const t = useAFFiNEI18N();
|
||||||
|
const workbench = useService(Workbench);
|
||||||
|
const views = useLiveData(workbench.views);
|
||||||
|
|
||||||
|
const viewIndex = views.findIndex(v => v === view);
|
||||||
|
|
||||||
|
const handleClose = useCallback(
|
||||||
|
() => workbench.close(view),
|
||||||
|
[view, workbench]
|
||||||
|
);
|
||||||
|
const handleMoveLeft = useCallback(() => {
|
||||||
|
workbench.moveView(viewIndex, viewIndex - 1);
|
||||||
|
}, [viewIndex, workbench]);
|
||||||
|
const handleMoveRight = useCallback(() => {
|
||||||
|
workbench.moveView(viewIndex, viewIndex + 1);
|
||||||
|
}, [viewIndex, workbench]);
|
||||||
|
const handleFullScreen = useCallback(() => {
|
||||||
|
workbench.closeOthers(view);
|
||||||
|
}, [view, workbench]);
|
||||||
|
|
||||||
|
const CloseItem =
|
||||||
|
views.length > 1 ? (
|
||||||
|
<MenuItem
|
||||||
|
preFix={<MenuIcon icon={<CloseIcon />} />}
|
||||||
|
onClick={handleClose}
|
||||||
|
>
|
||||||
|
{t['com.affine.workbench.split-view-menu.close']()}
|
||||||
|
</MenuItem>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
const MoveLeftItem =
|
||||||
|
viewIndex > 0 && views.length > 1 ? (
|
||||||
|
<MenuItem
|
||||||
|
onClick={handleMoveLeft}
|
||||||
|
preFix={<MenuIcon icon={<InsertLeftIcon />} />}
|
||||||
|
>
|
||||||
|
{t['com.affine.workbench.split-view-menu.move-left']()}
|
||||||
|
</MenuItem>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
const FullScreenItem =
|
||||||
|
views.length > 1 ? (
|
||||||
|
<MenuItem
|
||||||
|
onClick={handleFullScreen}
|
||||||
|
preFix={<MenuIcon icon={<ExpandFullIcon />} />}
|
||||||
|
>
|
||||||
|
{t['com.affine.workbench.split-view-menu.full-screen']()}
|
||||||
|
</MenuItem>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
const MoveRightItem =
|
||||||
|
viewIndex < views.length - 1 ? (
|
||||||
|
<MenuItem
|
||||||
|
onClick={handleMoveRight}
|
||||||
|
preFix={<MenuIcon icon={<InsertRightIcon />} />}
|
||||||
|
>
|
||||||
|
{t['com.affine.workbench.split-view-menu.move-right']()}
|
||||||
|
</MenuItem>
|
||||||
|
) : null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{MoveRightItem}
|
||||||
|
{MoveLeftItem}
|
||||||
|
{FullScreenItem}
|
||||||
|
{CloseItem}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,53 @@
|
|||||||
|
import { type HTMLAttributes, useCallback } from 'react';
|
||||||
|
|
||||||
|
import * as styles from './split-view.css';
|
||||||
|
|
||||||
|
interface ResizeHandleProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
resizing: boolean;
|
||||||
|
onResizeStart: () => void;
|
||||||
|
onResizeEnd: () => void;
|
||||||
|
onResizing: (offset: { x: number; y: number }) => void;
|
||||||
|
}
|
||||||
|
export const ResizeHandle = ({
|
||||||
|
resizing,
|
||||||
|
onResizing,
|
||||||
|
onResizeStart,
|
||||||
|
onResizeEnd,
|
||||||
|
}: ResizeHandleProps) => {
|
||||||
|
// TODO: touch support
|
||||||
|
const onMouseDown = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
onResizeStart();
|
||||||
|
const prevPos = { x: e.clientX, y: e.clientY };
|
||||||
|
|
||||||
|
function onMouseMove(e: MouseEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const dx = e.clientX - prevPos.x;
|
||||||
|
const dy = e.clientY - prevPos.y;
|
||||||
|
onResizing({ x: dx, y: dy });
|
||||||
|
prevPos.x = e.clientX;
|
||||||
|
prevPos.y = e.clientY;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseUp(e: MouseEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
onResizeEnd();
|
||||||
|
document.removeEventListener('mousemove', onMouseMove);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', onMouseMove);
|
||||||
|
document.addEventListener('mouseup', onMouseUp, { once: true });
|
||||||
|
},
|
||||||
|
[onResizeEnd, onResizeStart, onResizing]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onMouseDown={onMouseDown}
|
||||||
|
data-resizing={resizing || null}
|
||||||
|
className={styles.resizeHandle}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,135 @@
|
|||||||
|
import { cssVar } from '@toeverything/theme';
|
||||||
|
import { createVar, style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
const gap = createVar();
|
||||||
|
const borderRadius = createVar();
|
||||||
|
|
||||||
|
export const splitViewRoot = style({
|
||||||
|
vars: {
|
||||||
|
[gap]: '0px',
|
||||||
|
[borderRadius]: '0px',
|
||||||
|
},
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
borderRadius,
|
||||||
|
gap,
|
||||||
|
|
||||||
|
selectors: {
|
||||||
|
'&[data-client-border="true"]': {
|
||||||
|
vars: {
|
||||||
|
[gap]: '6px',
|
||||||
|
[borderRadius]: '6px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'&[data-orientation="vertical"]': {
|
||||||
|
flexDirection: 'column',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const splitViewPanel = style({
|
||||||
|
flexShrink: 0,
|
||||||
|
flexGrow: 'var(--size, 1)',
|
||||||
|
position: 'relative',
|
||||||
|
borderRadius: 'inherit',
|
||||||
|
|
||||||
|
selectors: {
|
||||||
|
'[data-orientation="vertical"] &': {
|
||||||
|
height: 0,
|
||||||
|
},
|
||||||
|
'[data-orientation="horizontal"] &': {
|
||||||
|
width: 0,
|
||||||
|
},
|
||||||
|
'[data-client-border="false"] &:not(:last-child):not([data-is-dragging="true"])':
|
||||||
|
{
|
||||||
|
borderRight: `1px solid ${cssVar('borderColor')}`,
|
||||||
|
},
|
||||||
|
'&[data-is-dragging="true"]': {
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const splitViewPanelDrag = style({
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 'inherit',
|
||||||
|
|
||||||
|
selectors: {
|
||||||
|
'&::after': {
|
||||||
|
content: '""',
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
borderRadius: 'inherit',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 10,
|
||||||
|
},
|
||||||
|
|
||||||
|
'[data-is-dragging="true"] &::after': {
|
||||||
|
boxShadow: `inset 0 0 0 2px ${cssVar('brandColor')}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const splitViewPanelContent = style({
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 'inherit',
|
||||||
|
overflow: 'hidden',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const resizeHandle = style({
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
right: -5,
|
||||||
|
width: 10,
|
||||||
|
// to make sure it's above all-pages's header
|
||||||
|
zIndex: 3,
|
||||||
|
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'stretch',
|
||||||
|
cursor: 'col-resize',
|
||||||
|
|
||||||
|
selectors: {
|
||||||
|
'[data-client-border="true"] &': {
|
||||||
|
right: `calc(-5px - ${gap} / 2)`,
|
||||||
|
},
|
||||||
|
|
||||||
|
// horizontal
|
||||||
|
'[data-orientation="horizontal"] &::before, [data-orientation="horizontal"] &::after':
|
||||||
|
{
|
||||||
|
content: '""',
|
||||||
|
width: 2,
|
||||||
|
position: 'absolute',
|
||||||
|
height: '100%',
|
||||||
|
background: 'transparent',
|
||||||
|
transition: 'background 0.1s',
|
||||||
|
borderRadius: 10,
|
||||||
|
},
|
||||||
|
'[data-orientation="horizontal"] &[data-resizing]::before, [data-orientation="horizontal"] &[data-resizing]::after':
|
||||||
|
{
|
||||||
|
width: 3,
|
||||||
|
},
|
||||||
|
|
||||||
|
'&:hover::before, &[data-resizing]::before': {
|
||||||
|
background: cssVar('brandColor'),
|
||||||
|
},
|
||||||
|
'&:hover::after, &[data-resizing]::after': {
|
||||||
|
boxShadow: `0px 12px 21px 4px ${cssVar('brandColor')}`,
|
||||||
|
opacity: 0.15,
|
||||||
|
},
|
||||||
|
|
||||||
|
// vertical
|
||||||
|
// TODO
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const menuTrigger = style({
|
||||||
|
position: 'absolute',
|
||||||
|
left: '50%',
|
||||||
|
top: 3,
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
zIndex: 10,
|
||||||
|
});
|
@ -0,0 +1,141 @@
|
|||||||
|
import { useAppSettingHelper } from '@affine/core/hooks/affine/use-app-setting-helper';
|
||||||
|
import {
|
||||||
|
closestCenter,
|
||||||
|
DndContext,
|
||||||
|
type DragEndEvent,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import {
|
||||||
|
horizontalListSortingStrategy,
|
||||||
|
SortableContext,
|
||||||
|
} from '@dnd-kit/sortable';
|
||||||
|
import { useService } from '@toeverything/infra/di';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import {
|
||||||
|
type HTMLAttributes,
|
||||||
|
type RefObject,
|
||||||
|
useCallback,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
|
import type { View } from '../../entities/view';
|
||||||
|
import { Workbench } from '../../entities/workbench';
|
||||||
|
import { SplitViewPanel } from './panel';
|
||||||
|
import { ResizeHandle } from './resize-handle';
|
||||||
|
import * as styles from './split-view.css';
|
||||||
|
|
||||||
|
export interface SplitViewProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
/**
|
||||||
|
* ⚠️ `vertical` orientation is not supported yet
|
||||||
|
* @default 'horizontal'
|
||||||
|
*/
|
||||||
|
orientation?: 'horizontal' | 'vertical';
|
||||||
|
views: View[];
|
||||||
|
renderer: (item: View, index: number) => React.ReactNode;
|
||||||
|
onMove?: (from: number, to: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SlotsMap = Record<View['id'], RefObject<HTMLDivElement | null>>;
|
||||||
|
// TODO: vertical orientation support
|
||||||
|
export const SplitView = ({
|
||||||
|
orientation = 'horizontal',
|
||||||
|
className,
|
||||||
|
views,
|
||||||
|
renderer,
|
||||||
|
onMove,
|
||||||
|
...attrs
|
||||||
|
}: SplitViewProps) => {
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [slots, setSlots] = useState<SlotsMap>({});
|
||||||
|
const [resizingViewId, setResizingViewId] = useState<View['id'] | null>(null);
|
||||||
|
const { appSettings } = useAppSettingHelper();
|
||||||
|
const workbench = useService(Workbench);
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 2,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const onResizing = useCallback(
|
||||||
|
(index: number, { x, y }: { x: number; y: number }) => {
|
||||||
|
const rootEl = rootRef.current;
|
||||||
|
if (!rootEl) return;
|
||||||
|
|
||||||
|
const rootRect = rootEl.getBoundingClientRect();
|
||||||
|
const offset = orientation === 'horizontal' ? x : y;
|
||||||
|
const total =
|
||||||
|
orientation === 'horizontal' ? rootRect.width : rootRect.height;
|
||||||
|
|
||||||
|
const percent = offset / total;
|
||||||
|
workbench.resize(index, percent);
|
||||||
|
},
|
||||||
|
[orientation, workbench]
|
||||||
|
);
|
||||||
|
|
||||||
|
const resizeHandleRenderer = useCallback(
|
||||||
|
(view: View, index: number) =>
|
||||||
|
index < views.length - 1 ? (
|
||||||
|
<ResizeHandle
|
||||||
|
resizing={resizingViewId === view.id}
|
||||||
|
onResizeStart={() => setResizingViewId(view.id)}
|
||||||
|
onResizeEnd={() => setResizingViewId(null)}
|
||||||
|
onResizing={dxy => onResizing(index, dxy)}
|
||||||
|
/>
|
||||||
|
) : null,
|
||||||
|
[onResizing, resizingViewId, views.length]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(
|
||||||
|
(event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (active.id !== over?.id) {
|
||||||
|
// update order
|
||||||
|
const fromIndex = views.findIndex(v => v.id === active.id);
|
||||||
|
const toIndex = views.findIndex(v => v.id === over?.id);
|
||||||
|
onMove?.(fromIndex, toIndex);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onMove, views]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={rootRef}
|
||||||
|
className={clsx(styles.splitViewRoot, className)}
|
||||||
|
data-orientation={orientation}
|
||||||
|
data-client-border={appSettings.clientBorder}
|
||||||
|
{...attrs}
|
||||||
|
>
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<SortableContext items={views} strategy={horizontalListSortingStrategy}>
|
||||||
|
{views.map((view, index) => (
|
||||||
|
<SplitViewPanel view={view} key={view.id} setSlots={setSlots}>
|
||||||
|
{resizeHandleRenderer(view, index)}
|
||||||
|
</SplitViewPanel>
|
||||||
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
|
||||||
|
{views.map((view, index) => {
|
||||||
|
const slot = slots[view.id]?.current;
|
||||||
|
if (!slot) return null;
|
||||||
|
return createPortal(
|
||||||
|
renderer(view, index),
|
||||||
|
slot,
|
||||||
|
`portalToSplitViewPanel_${view.id}`
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -5,11 +5,6 @@ export const workbenchRootContainer = style({
|
|||||||
height: '100%',
|
height: '100%',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
selectors: {
|
|
||||||
[`&[data-client-border="true"]`]: {
|
|
||||||
gap: '8px',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const workbenchViewContainer = style({
|
export const workbenchViewContainer = style({
|
||||||
|
@ -1,7 +1,5 @@
|
|||||||
import { appSettingAtom } from '@toeverything/infra/atom';
|
|
||||||
import { useService } from '@toeverything/infra/di';
|
import { useService } from '@toeverything/infra/di';
|
||||||
import { useLiveData } from '@toeverything/infra/livedata';
|
import { useLiveData } from '@toeverything/infra/livedata';
|
||||||
import { useAtomValue } from 'jotai';
|
|
||||||
import { useCallback, useEffect, useRef } from 'react';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
@ -9,6 +7,7 @@ import type { View } from '../entities/view';
|
|||||||
import { Workbench } from '../entities/workbench';
|
import { Workbench } from '../entities/workbench';
|
||||||
import { useBindWorkbenchToBrowserRouter } from './browser-adapter';
|
import { useBindWorkbenchToBrowserRouter } from './browser-adapter';
|
||||||
import { useBindWorkbenchToDesktopRouter } from './desktop-adapter';
|
import { useBindWorkbenchToDesktopRouter } from './desktop-adapter';
|
||||||
|
import { SplitView } from './split-view/split-view';
|
||||||
import { ViewRoot } from './view-root';
|
import { ViewRoot } from './view-root';
|
||||||
import * as styles from './workbench-root.css';
|
import * as styles from './workbench-root.css';
|
||||||
|
|
||||||
@ -29,17 +28,24 @@ export const WorkbenchRoot = () => {
|
|||||||
|
|
||||||
useAdapter(workbench, basename);
|
useAdapter(workbench, basename);
|
||||||
|
|
||||||
const { clientBorder } = useAtomValue(appSettingAtom);
|
const panelRenderer = useCallback((view: View, index: number) => {
|
||||||
|
return <WorkbenchView key={view.id} view={view} index={index} />;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onMove = useCallback(
|
||||||
|
(from: number, to: number) => {
|
||||||
|
workbench.moveView(from, to);
|
||||||
|
},
|
||||||
|
[workbench]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<SplitView
|
||||||
className={styles.workbenchRootContainer}
|
className={styles.workbenchRootContainer}
|
||||||
data-client-border={!!clientBorder}
|
views={views}
|
||||||
>
|
renderer={panelRenderer}
|
||||||
{views.map((view, index) => (
|
onMove={onMove}
|
||||||
<WorkbenchView key={view.id} view={view} index={index} />
|
/>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1142,5 +1142,9 @@
|
|||||||
"com.affine.share-page.footer.create-with": "Create with",
|
"com.affine.share-page.footer.create-with": "Create with",
|
||||||
"com.affine.share-page.footer.built-with": "Built with",
|
"com.affine.share-page.footer.built-with": "Built with",
|
||||||
"com.affine.share-page.footer.description": "Empower your sharing with AffiNE Cloud: One-click doc sharing",
|
"com.affine.share-page.footer.description": "Empower your sharing with AffiNE Cloud: One-click doc sharing",
|
||||||
"com.affine.share-page.footer.get-started": "Get started for free"
|
"com.affine.share-page.footer.get-started": "Get started for free",
|
||||||
|
"com.affine.workbench.split-view-menu.close": "Close",
|
||||||
|
"com.affine.workbench.split-view-menu.move-left": "Move Left",
|
||||||
|
"com.affine.workbench.split-view-menu.move-right": "Move Right",
|
||||||
|
"com.affine.workbench.split-view-menu.full-screen": "Full Screen"
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user