diff --git a/packages/frontend/component/src/ui/menu/styles.css.ts b/packages/frontend/component/src/ui/menu/styles.css.ts index 1e7b498256..e950895875 100644 --- a/packages/frontend/component/src/ui/menu/styles.css.ts +++ b/packages/frontend/component/src/ui/menu/styles.css.ts @@ -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', diff --git a/packages/frontend/core/src/modules/workbench/entities/view.ts b/packages/frontend/core/src/modules/workbench/entities/view.ts index 8899e56dba..ac2db10803 100644 --- a/packages/frontend/core/src/modules/workbench/entities/view.ts +++ b/packages/frontend/core/src/modules/workbench/entities/view.ts @@ -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); + } } diff --git a/packages/frontend/core/src/modules/workbench/entities/workbench.ts b/packages/frontend/core/src/modules/workbench/entities/workbench.ts index b7a0168524..fd7c73c10b 100644 --- a/packages/frontend/core/src/modules/workbench/entities/workbench.ts +++ b/packages/frontend/core/src/modules/workbench/entities/workbench.ts @@ -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; diff --git a/packages/frontend/core/src/modules/workbench/view/split-view/indicator.css.ts b/packages/frontend/core/src/modules/workbench/view/split-view/indicator.css.ts new file mode 100644 index 0000000000..4d9a5a1bb5 --- /dev/null +++ b/packages/frontend/core/src/modules/workbench/view/split-view/indicator.css.ts @@ -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', +}); diff --git a/packages/frontend/core/src/modules/workbench/view/split-view/indicator.tsx b/packages/frontend/core/src/modules/workbench/view/split-view/indicator.tsx new file mode 100644 index 0000000000..8cb53c4b64 --- /dev/null +++ b/packages/frontend/core/src/modules/workbench/view/split-view/indicator.tsx @@ -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 { + active?: boolean; +} + +export const SplitViewMenuIndicator = memo( + forwardRef( + function SplitViewMenuIndicator( + { className, active, ...attrs }: SplitViewMenuProps, + ref + ) { + return ( +
+
+
+ ); + } + ) +); diff --git a/packages/frontend/core/src/modules/workbench/view/split-view/panel.tsx b/packages/frontend/core/src/modules/workbench/view/split-view/panel.tsx new file mode 100644 index 0000000000..bbe7787c45 --- /dev/null +++ b/packages/frontend/core/src/modules/workbench/view/split-view/panel.tsx @@ -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> { + view: View; + resizeHandle?: React.ReactNode; + setSlots?: Dispatch< + SetStateAction>> + >; +} + +export const SplitViewPanel = memo(function SplitViewPanel({ + children, + view, + setSlots, +}: SplitViewPanelProps) { + const ref = useRef(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 ( +
+
+
+ {views.length > 1 ? ( + } + rootOptions={menuRootOptions} + > + + + ) : null} +
+ {children} +
+ ); +}); + +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 ? ( + } />} + onClick={handleClose} + > + {t['com.affine.workbench.split-view-menu.close']()} + + ) : null; + + const MoveLeftItem = + viewIndex > 0 && views.length > 1 ? ( + } />} + > + {t['com.affine.workbench.split-view-menu.move-left']()} + + ) : null; + + const FullScreenItem = + views.length > 1 ? ( + } />} + > + {t['com.affine.workbench.split-view-menu.full-screen']()} + + ) : null; + + const MoveRightItem = + viewIndex < views.length - 1 ? ( + } />} + > + {t['com.affine.workbench.split-view-menu.move-right']()} + + ) : null; + return ( + <> + {MoveRightItem} + {MoveLeftItem} + {FullScreenItem} + {CloseItem} + + ); +}; diff --git a/packages/frontend/core/src/modules/workbench/view/split-view/resize-handle.tsx b/packages/frontend/core/src/modules/workbench/view/split-view/resize-handle.tsx new file mode 100644 index 0000000000..26f52d218d --- /dev/null +++ b/packages/frontend/core/src/modules/workbench/view/split-view/resize-handle.tsx @@ -0,0 +1,53 @@ +import { type HTMLAttributes, useCallback } from 'react'; + +import * as styles from './split-view.css'; + +interface ResizeHandleProps extends HTMLAttributes { + 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 ( +
+ ); +}; diff --git a/packages/frontend/core/src/modules/workbench/view/split-view/split-view.css.ts b/packages/frontend/core/src/modules/workbench/view/split-view/split-view.css.ts new file mode 100644 index 0000000000..a7b579b60a --- /dev/null +++ b/packages/frontend/core/src/modules/workbench/view/split-view/split-view.css.ts @@ -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, +}); diff --git a/packages/frontend/core/src/modules/workbench/view/split-view/split-view.tsx b/packages/frontend/core/src/modules/workbench/view/split-view/split-view.tsx new file mode 100644 index 0000000000..0b68aa0b41 --- /dev/null +++ b/packages/frontend/core/src/modules/workbench/view/split-view/split-view.tsx @@ -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 { + /** + * ⚠️ `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>; +// TODO: vertical orientation support +export const SplitView = ({ + orientation = 'horizontal', + className, + views, + renderer, + onMove, + ...attrs +}: SplitViewProps) => { + const rootRef = useRef(null); + const [slots, setSlots] = useState({}); + const [resizingViewId, setResizingViewId] = useState(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 ? ( + 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 ( +
+ + + {views.map((view, index) => ( + + {resizeHandleRenderer(view, index)} + + ))} + + + + {views.map((view, index) => { + const slot = slots[view.id]?.current; + if (!slot) return null; + return createPortal( + renderer(view, index), + slot, + `portalToSplitViewPanel_${view.id}` + ); + })} +
+ ); +}; diff --git a/packages/frontend/core/src/modules/workbench/view/workbench-root.css.ts b/packages/frontend/core/src/modules/workbench/view/workbench-root.css.ts index 4b4f1305b1..4193b0c83d 100644 --- a/packages/frontend/core/src/modules/workbench/view/workbench-root.css.ts +++ b/packages/frontend/core/src/modules/workbench/view/workbench-root.css.ts @@ -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({ diff --git a/packages/frontend/core/src/modules/workbench/view/workbench-root.tsx b/packages/frontend/core/src/modules/workbench/view/workbench-root.tsx index 0b997aae3e..771ca4d5cb 100644 --- a/packages/frontend/core/src/modules/workbench/view/workbench-root.tsx +++ b/packages/frontend/core/src/modules/workbench/view/workbench-root.tsx @@ -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 ; + }, []); + + const onMove = useCallback( + (from: number, to: number) => { + workbench.moveView(from, to); + }, + [workbench] + ); return ( -
- {views.map((view, index) => ( - - ))} -
+ views={views} + renderer={panelRenderer} + onMove={onMove} + /> ); }; diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index cc811bef57..a2b60c2fb6 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -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" }