feat(core): resize and reorder split-view (#5994)

This commit is contained in:
Cats Juice 2024-03-04 11:19:39 +00:00
parent 7b31363c51
commit 2275eee5b2
No known key found for this signature in database
GPG Key ID: 1C1E76924FAFDDE4
12 changed files with 661 additions and 16 deletions

View File

@ -11,6 +11,7 @@ export const menuContent = style({
backgroundColor: cssVar('backgroundOverlayPanelColor'),
boxShadow: cssVar('menuShadow'),
userSelect: 'none',
['WebkitAppRegion' as string]: 'no-drag',
});
export const menuItem = style({
display: 'flex',

View File

@ -20,6 +20,7 @@ export class View {
}),
this.history.location
);
size = new LiveData(100);
header = createIsland();
body = createIsland();
@ -35,4 +36,8 @@ export class View {
replace(path: To) {
this.history.replace(path);
}
setSize(size?: number) {
this.size.next(size ?? 100);
}
}

View File

@ -89,6 +89,48 @@ export class Workbench {
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 {
if (positionIndex === 'active') {
return this.activeViewIndex.value;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,11 +5,6 @@ export const workbenchRootContainer = style({
height: '100%',
flex: 1,
overflow: 'hidden',
selectors: {
[`&[data-client-border="true"]`]: {
gap: '8px',
},
},
});
export const workbenchViewContainer = style({

View File

@ -1,7 +1,5 @@
import { appSettingAtom } from '@toeverything/infra/atom';
import { useService } from '@toeverything/infra/di';
import { useLiveData } from '@toeverything/infra/livedata';
import { useAtomValue } from 'jotai';
import { useCallback, useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
@ -9,6 +7,7 @@ import type { View } from '../entities/view';
import { Workbench } from '../entities/workbench';
import { useBindWorkbenchToBrowserRouter } from './browser-adapter';
import { useBindWorkbenchToDesktopRouter } from './desktop-adapter';
import { SplitView } from './split-view/split-view';
import { ViewRoot } from './view-root';
import * as styles from './workbench-root.css';
@ -29,17 +28,24 @@ export const WorkbenchRoot = () => {
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 (
<div
<SplitView
className={styles.workbenchRootContainer}
data-client-border={!!clientBorder}
>
{views.map((view, index) => (
<WorkbenchView key={view.id} view={view} index={index} />
))}
</div>
views={views}
renderer={panelRenderer}
onMove={onMove}
/>
);
};

View File

@ -1142,5 +1142,9 @@
"com.affine.share-page.footer.create-with": "Create 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.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"
}