mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 20:38:52 +00:00
feat(core): drop doc onto split view (#9487)
fix AF-2068, AF-2069, AF-1175, AF-2061, AF-2079, AF-2034, AF-2080, AF-1960, AF-2081 1. replace `dnd-kit` with `@atlaskit/pragmatic-drag-and-drop` 2. allow creating split views by drag & drop the following a. WorkbenchLinks (route links), like journals, trash, all docs b. doc refs c. tags/collection 3. style adjustments to split view 4. remove split view's feature flag and make it GA for electron https://github.com/user-attachments/assets/6a3e4a25-faa2-4215-8eb0-983f44db6e8c
This commit is contained in:
@@ -96,7 +96,6 @@ export const showTabContextMenu = async (
|
||||
];
|
||||
const menu = Menu.buildFromTemplate(template);
|
||||
menu.popup();
|
||||
|
||||
let unsub: (() => void) | undefined;
|
||||
const subscription = WebContentViewsManager.instance.tabAction$.subscribe(
|
||||
action => {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { assignInlineVars } from '@vanilla-extract/dynamic';
|
||||
import clsx from 'clsx';
|
||||
import { forwardRef, useCallback, useLayoutEffect, useRef } from 'react';
|
||||
import { useTransition } from 'react-transition-state';
|
||||
import { useTransitionState } from 'react-transition-state';
|
||||
|
||||
import { useDropTarget } from '../../ui/dnd';
|
||||
import { Tooltip, type TooltipProps } from '../../ui/tooltip';
|
||||
import * as styles from './resize-panel.css';
|
||||
|
||||
@@ -22,6 +23,7 @@ export interface ResizeHandleProps
|
||||
tooltipShortcut?: TooltipProps['shortcut'];
|
||||
tooltipOptions?: Partial<Omit<TooltipProps, 'content' | 'shortcut'>>;
|
||||
tooltipShortcutClassName?: string;
|
||||
dropTargetOptions?: Parameters<typeof useDropTarget>[0];
|
||||
}
|
||||
|
||||
export interface ResizePanelProps
|
||||
@@ -40,6 +42,7 @@ export interface ResizePanelProps
|
||||
resizeHandleTooltipOptions?: Partial<
|
||||
Omit<TooltipProps, 'content' | 'shortcut'>
|
||||
>;
|
||||
resizeHandleDropTargetOptions?: Parameters<typeof useDropTarget>[0];
|
||||
enableAnimation?: boolean;
|
||||
width: number;
|
||||
unmountOnExit?: boolean;
|
||||
@@ -56,6 +59,7 @@ const ResizeHandle = ({
|
||||
resizeHandlePos,
|
||||
resizeHandleOffset,
|
||||
resizeHandleVerticalPadding,
|
||||
dropTargetOptions,
|
||||
open,
|
||||
onOpen,
|
||||
onResizing,
|
||||
@@ -115,6 +119,10 @@ const ResizeHandle = ({
|
||||
[maxWidth, resizeHandlePos, minWidth, onWidthChange, onResizing, onOpen]
|
||||
);
|
||||
|
||||
const { dropTargetRef } = useDropTarget(dropTargetOptions, [
|
||||
dropTargetOptions,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
content={tooltip}
|
||||
@@ -125,7 +133,10 @@ const ResizeHandle = ({
|
||||
<div
|
||||
{...rest}
|
||||
data-testid="resize-handle"
|
||||
ref={ref}
|
||||
ref={node => {
|
||||
ref.current = node;
|
||||
dropTargetRef.current = node;
|
||||
}}
|
||||
style={assignInlineVars({
|
||||
[styles.resizeHandleOffsetVar]: `${resizeHandleOffset ?? 0}px`,
|
||||
[styles.resizeHandleVerticalPadding]: `${
|
||||
@@ -169,17 +180,18 @@ export const ResizePanel = forwardRef<HTMLDivElement, ResizePanelProps>(
|
||||
resizeHandleTooltipShortcut,
|
||||
resizeHandleTooltipShortcutClassName,
|
||||
resizeHandleTooltipOptions,
|
||||
resizeHandleDropTargetOptions,
|
||||
...rest
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const safeWidth = Math.min(maxWidth, Math.max(minWidth, width));
|
||||
const [{ status }, toggle] = useTransition({
|
||||
const [{ status }, toggle] = useTransitionState({
|
||||
timeout: animationTimeout,
|
||||
});
|
||||
useLayoutEffect(() => {
|
||||
toggle(open);
|
||||
}, [open]);
|
||||
}, [open, toggle]);
|
||||
return (
|
||||
<div
|
||||
{...rest}
|
||||
@@ -213,6 +225,7 @@ export const ResizePanel = forwardRef<HTMLDivElement, ResizePanelProps>(
|
||||
onWidthChange={onWidthChange}
|
||||
open={open}
|
||||
resizing={resizing}
|
||||
dropTargetOptions={resizeHandleDropTargetOptions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
33
packages/frontend/component/src/ui/dnd/common.ts
Normal file
33
packages/frontend/component/src/ui/dnd/common.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { DNDData, fromExternalData } from './types';
|
||||
|
||||
export const isExternalDrag = <D extends DNDData>(args: {
|
||||
source: { data: D['draggable'] };
|
||||
}) => {
|
||||
return !args.source['data'];
|
||||
};
|
||||
|
||||
export const getAdaptedEventArgs = <
|
||||
D extends DNDData,
|
||||
Args extends { source: { data: D['draggable'] } },
|
||||
>(
|
||||
args: Args,
|
||||
fromExternalData?: fromExternalData<D>,
|
||||
isDropEvent = false
|
||||
): Args => {
|
||||
const data =
|
||||
isExternalDrag(args) && fromExternalData
|
||||
? fromExternalData(
|
||||
// @ts-expect-error hack for external data adapter (source has no data field)
|
||||
args as ExternalGetDataFeedbackArgs,
|
||||
isDropEvent
|
||||
)
|
||||
: args.source['data'];
|
||||
|
||||
return {
|
||||
...args,
|
||||
source: {
|
||||
...args.source,
|
||||
data,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -71,7 +71,6 @@ export const useDraggable = <D extends DNDData = DNDData>(
|
||||
|
||||
const context = useContext(DNDContext);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const options = useMemo(() => {
|
||||
const opts = getOptions();
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
} from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item';
|
||||
import { useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { shallowUpdater } from '../../utils';
|
||||
import { getAdaptedEventArgs, isExternalDrag } from './common';
|
||||
import { DNDContext } from './context';
|
||||
import type { DNDData, fromExternalData } from './types';
|
||||
|
||||
@@ -44,7 +46,7 @@ export type DropTargetTreeInstruction = Instruction;
|
||||
|
||||
export type ExternalDragPayload = ExternalDragType['payload'];
|
||||
|
||||
type DropTargetGetFeedback<D extends DNDData> = Parameters<
|
||||
export type DropTargetGetFeedback<D extends DNDData> = Parameters<
|
||||
NonNullable<Parameters<typeof dropTargetForElements>[0]['canDrop']>
|
||||
>[0] & {
|
||||
source: {
|
||||
@@ -59,38 +61,6 @@ type DropTargetGet<T, D extends DNDData> =
|
||||
| T
|
||||
| ((data: DropTargetGetFeedback<D>) => T);
|
||||
|
||||
const isExternalDrag = <D extends DNDData>(
|
||||
args: Pick<DropTargetGetFeedback<D>, 'source'>
|
||||
) => {
|
||||
return !args.source['data'];
|
||||
};
|
||||
|
||||
const getAdaptedEventArgs = <
|
||||
D extends DNDData,
|
||||
Args extends Pick<DropTargetGetFeedback<D>, 'source'>,
|
||||
>(
|
||||
options: DropTargetOptions<D>,
|
||||
args: Args,
|
||||
isDropEvent = false
|
||||
): Args => {
|
||||
const data =
|
||||
isExternalDrag(args) && options.fromExternalData
|
||||
? options.fromExternalData(
|
||||
// @ts-expect-error hack for external data adapter (source has no data field)
|
||||
args as ExternalGetDataFeedbackArgs,
|
||||
isDropEvent
|
||||
)
|
||||
: args.source['data'];
|
||||
|
||||
return {
|
||||
...args,
|
||||
source: {
|
||||
...args.source,
|
||||
data,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
function dropTargetGet<T, D extends DNDData>(
|
||||
get: T,
|
||||
options: DropTargetOptions<D>
|
||||
@@ -110,7 +80,7 @@ function dropTargetGet<T, D extends DNDData>(
|
||||
) => {
|
||||
if (typeof get === 'function') {
|
||||
return (get as any)({
|
||||
...getAdaptedEventArgs(options, args),
|
||||
...getAdaptedEventArgs(args, options.fromExternalData),
|
||||
get treeInstruction() {
|
||||
return options.treeInstruction
|
||||
? extractInstruction(
|
||||
@@ -145,8 +115,8 @@ function dropTargetGet<T, D extends DNDData>(
|
||||
});
|
||||
} else {
|
||||
return {
|
||||
...getAdaptedEventArgs(args, options.fromExternalData),
|
||||
...get,
|
||||
...getAdaptedEventArgs(options, args),
|
||||
};
|
||||
}
|
||||
}) as any;
|
||||
@@ -168,6 +138,8 @@ export interface DropTargetOptions<D extends DNDData = DNDData> {
|
||||
};
|
||||
onDrop?: (data: DropTargetDropEvent<D>) => void;
|
||||
onDrag?: (data: DropTargetDragEvent<D>) => void;
|
||||
onDragEnter?: (data: DropTargetDragEvent<D>) => void;
|
||||
onDragLeave?: (data: DropTargetDragEvent<D>) => void;
|
||||
/**
|
||||
* external data adapter.
|
||||
* Will use the external data adapter from the context if not provided.
|
||||
@@ -232,6 +204,55 @@ export const useDropTarget = <D extends DNDData = DNDData>(
|
||||
const dropTargetOptions = useMemo(() => {
|
||||
const wrappedCanDrop = dropTargetGet(options.canDrop, options);
|
||||
let _element: HTMLElement | null = null;
|
||||
|
||||
const updateDragOver = (
|
||||
args: DropTargetDragEvent<D>,
|
||||
handler?: (data: DropTargetDragEvent<D>) => void
|
||||
) => {
|
||||
args = getAdaptedEventArgs(args, options.fromExternalData);
|
||||
if (
|
||||
args.location.current.dropTargets[0]?.element === dropTargetRef.current
|
||||
) {
|
||||
if (enableDraggedOverDraggable.current) {
|
||||
setDraggedOverDraggable(shallowUpdater(args.source));
|
||||
}
|
||||
let instruction = null;
|
||||
let closestEdge = null;
|
||||
if (options.treeInstruction) {
|
||||
instruction = extractInstruction(args.self.data);
|
||||
setTreeInstruction(shallowUpdater(instruction));
|
||||
if (dropTargetRef.current) {
|
||||
dropTargetRef.current.dataset['treeInstruction'] =
|
||||
instruction?.type;
|
||||
}
|
||||
}
|
||||
if (options.closestEdge) {
|
||||
closestEdge = extractClosestEdge(args.self.data);
|
||||
setClosestEdge(shallowUpdater(closestEdge));
|
||||
}
|
||||
if (enableDropEffect.current) {
|
||||
setDropEffect(shallowUpdater(args.self.dropEffect));
|
||||
}
|
||||
if (enableDraggedOverPosition.current) {
|
||||
const rect = args.self.element.getBoundingClientRect();
|
||||
const { clientX, clientY } = args.location.current.input;
|
||||
setDraggedOverPosition(
|
||||
shallowUpdater({
|
||||
relativeX: clientX - rect.x,
|
||||
relativeY: clientY - rect.y,
|
||||
clientX: clientX,
|
||||
clientY: clientY,
|
||||
})
|
||||
);
|
||||
}
|
||||
handler?.({
|
||||
...args,
|
||||
treeInstruction: instruction,
|
||||
closestEdge,
|
||||
} as DropTargetDropEvent<D>);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
get element() {
|
||||
if (!_element) {
|
||||
@@ -285,7 +306,7 @@ export const useDropTarget = <D extends DNDData = DNDData>(
|
||||
|
||||
// external data is only available in drop event thus
|
||||
// this is the only case for getAdaptedEventArgs
|
||||
const args = getAdaptedEventArgs(options, _args, true);
|
||||
const args = getAdaptedEventArgs(_args, options.fromExternalData, true);
|
||||
if (
|
||||
isExternalDrag(_args) &&
|
||||
options.fromExternalData &&
|
||||
@@ -309,7 +330,7 @@ export const useDropTarget = <D extends DNDData = DNDData>(
|
||||
}
|
||||
},
|
||||
getData: (args: DropTargetGetFeedback<D>) => {
|
||||
args = getAdaptedEventArgs(options, args);
|
||||
args = getAdaptedEventArgs(args, options.fromExternalData);
|
||||
const originData = dropTargetGet(options.data ?? {}, options)(args);
|
||||
const { input, element } = args;
|
||||
const withInstruction = options.treeInstruction
|
||||
@@ -332,50 +353,29 @@ export const useDropTarget = <D extends DNDData = DNDData>(
|
||||
return withClosestEdge;
|
||||
},
|
||||
onDrag: (args: DropTargetDragEvent<D>) => {
|
||||
args = getAdaptedEventArgs(options, args);
|
||||
if (
|
||||
args.location.current.dropTargets[0]?.element ===
|
||||
dropTargetRef.current
|
||||
) {
|
||||
if (enableDraggedOverDraggable.current) {
|
||||
setDraggedOverDraggable(args.source);
|
||||
}
|
||||
let instruction = null;
|
||||
let closestEdge = null;
|
||||
if (options.treeInstruction) {
|
||||
instruction = extractInstruction(args.self.data);
|
||||
setTreeInstruction(instruction);
|
||||
if (dropTargetRef.current) {
|
||||
dropTargetRef.current.dataset['treeInstruction'] =
|
||||
instruction?.type;
|
||||
}
|
||||
}
|
||||
if (options.closestEdge) {
|
||||
closestEdge = extractClosestEdge(args.self.data);
|
||||
setClosestEdge(closestEdge);
|
||||
}
|
||||
if (enableDropEffect.current) {
|
||||
setDropEffect(args.self.dropEffect);
|
||||
}
|
||||
if (enableDraggedOverPosition.current) {
|
||||
const rect = args.self.element.getBoundingClientRect();
|
||||
const { clientX, clientY } = args.location.current.input;
|
||||
setDraggedOverPosition({
|
||||
relativeX: clientX - rect.x,
|
||||
relativeY: clientY - rect.y,
|
||||
clientX: clientX,
|
||||
clientY: clientY,
|
||||
});
|
||||
}
|
||||
options.onDrag?.({
|
||||
...args,
|
||||
treeInstruction: instruction,
|
||||
closestEdge,
|
||||
} as DropTargetDropEvent<D>);
|
||||
}
|
||||
updateDragOver(args, options.onDrag);
|
||||
},
|
||||
onDragEnter: (args: DropTargetDragEvent<D>) => {
|
||||
updateDragOver(args, options.onDragEnter);
|
||||
},
|
||||
onDragLeave: (args: DropTargetDragEvent<D>) => {
|
||||
args = getAdaptedEventArgs(args, options.fromExternalData);
|
||||
|
||||
const withClosestEdge = options.closestEdge
|
||||
? attachClosestEdge(args.self.data, {
|
||||
element: args.self.element,
|
||||
input: args.location.current.input,
|
||||
allowedEdges: options.closestEdge.allowedEdges,
|
||||
})
|
||||
: args.self.data;
|
||||
|
||||
options.onDragLeave?.({
|
||||
...args,
|
||||
self: { ...args.self, data: withClosestEdge },
|
||||
});
|
||||
},
|
||||
onDropTargetChange: (args: DropTargetDropEvent<D>) => {
|
||||
args = getAdaptedEventArgs(options, args);
|
||||
args = getAdaptedEventArgs(args, options.fromExternalData);
|
||||
if (
|
||||
args.location.current.dropTargets[0]?.element ===
|
||||
dropTargetRef.current
|
||||
|
||||
@@ -2,4 +2,5 @@ export * from './context';
|
||||
export * from './draggable';
|
||||
export * from './drop-indicator';
|
||||
export * from './drop-target';
|
||||
export * from './monitor';
|
||||
export * from './types';
|
||||
|
||||
121
packages/frontend/component/src/ui/dnd/monitor.tsx
Normal file
121
packages/frontend/component/src/ui/dnd/monitor.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||||
import { monitorForExternal } from '@atlaskit/pragmatic-drag-and-drop/external/adapter';
|
||||
import type {
|
||||
DragLocationHistory,
|
||||
ElementDragType,
|
||||
} from '@atlaskit/pragmatic-drag-and-drop/types';
|
||||
import { useContext, useEffect, useMemo } from 'react';
|
||||
|
||||
import { getAdaptedEventArgs } from './common';
|
||||
import { DNDContext } from './context';
|
||||
import type { DNDData, fromExternalData } from './types';
|
||||
|
||||
type MonitorGetFeedback<D extends DNDData = DNDData> = Parameters<
|
||||
NonNullable<Parameters<typeof monitorForElements>[0]['canMonitor']>
|
||||
>[0] & {
|
||||
source: {
|
||||
data: D['draggable'];
|
||||
};
|
||||
};
|
||||
|
||||
type MonitorGet<T, D extends DNDData = DNDData> =
|
||||
| T
|
||||
| ((data: MonitorGetFeedback<D>) => T);
|
||||
|
||||
type MonitorDragEvent<D extends DNDData = DNDData> = {
|
||||
/**
|
||||
* Location history for the drag operation
|
||||
*/
|
||||
location: DragLocationHistory;
|
||||
/**
|
||||
* Data associated with the entity that is being dragged
|
||||
*/
|
||||
source: Exclude<ElementDragType['payload'], 'data'> & {
|
||||
data: D['draggable'];
|
||||
};
|
||||
};
|
||||
|
||||
export interface MonitorOptions<D extends DNDData = DNDData> {
|
||||
canMonitor?: MonitorGet<boolean, D>;
|
||||
onDragStart?: (data: MonitorDragEvent<D>) => void;
|
||||
onDrag?: (data: MonitorDragEvent<D>) => void;
|
||||
onDrop?: (data: MonitorDragEvent<D>) => void;
|
||||
onDropTargetChange?: (data: MonitorDragEvent<D>) => void;
|
||||
/**
|
||||
* external data adapter.
|
||||
* Will use the external data adapter from the context if not provided.
|
||||
*/
|
||||
fromExternalData?: fromExternalData<D>;
|
||||
/**
|
||||
* Make the drop target allow external data.
|
||||
* If this is undefined, it will be set to true if fromExternalData is provided.
|
||||
*
|
||||
* @default undefined
|
||||
*/
|
||||
allowExternal?: boolean;
|
||||
}
|
||||
|
||||
function monitorGet<D extends DNDData, T>(
|
||||
get: T,
|
||||
options: MonitorOptions<D>
|
||||
): T extends undefined
|
||||
? undefined
|
||||
: T extends MonitorGet<infer I>
|
||||
? (args: MonitorGetFeedback<D>) => I
|
||||
: never {
|
||||
if (get === undefined) {
|
||||
return undefined as any;
|
||||
}
|
||||
return ((args: MonitorGetFeedback<D>) => {
|
||||
const adaptedArgs = getAdaptedEventArgs(args, options.fromExternalData);
|
||||
return typeof get === 'function'
|
||||
? (get as any)(adaptedArgs)
|
||||
: {
|
||||
...adaptedArgs,
|
||||
...get,
|
||||
};
|
||||
}) as any;
|
||||
}
|
||||
|
||||
export const useDndMonitor = <D extends DNDData = DNDData>(
|
||||
getOptions: () => MonitorOptions<D> = () => ({}),
|
||||
deps: any[] = []
|
||||
) => {
|
||||
const dropTargetContext = useContext(DNDContext);
|
||||
|
||||
const options = useMemo(() => {
|
||||
const opts = getOptions();
|
||||
const allowExternal = opts.allowExternal ?? !!opts.fromExternalData;
|
||||
return {
|
||||
...opts,
|
||||
allowExternal,
|
||||
fromExternalData: allowExternal
|
||||
? (opts.fromExternalData ??
|
||||
(dropTargetContext.fromExternalData as fromExternalData<D>))
|
||||
: undefined,
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [...deps, getOptions]);
|
||||
|
||||
const monitorOptions = useMemo(() => {
|
||||
return {
|
||||
canMonitor: monitorGet(options.canMonitor, options),
|
||||
onDragStart: monitorGet(options.onDragStart, options),
|
||||
onDrag: monitorGet(options.onDrag, options),
|
||||
onDrop: monitorGet(options.onDrop, options),
|
||||
onDropTargetChange: monitorGet(options.onDropTargetChange, options),
|
||||
};
|
||||
}, [options]);
|
||||
|
||||
useEffect(() => {
|
||||
return monitorForElements(monitorOptions);
|
||||
}, [monitorOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!options.fromExternalData) {
|
||||
return;
|
||||
}
|
||||
// @ts-expect-error external & element adapter types have some subtle differences
|
||||
return monitorForExternal(monitorOptions);
|
||||
}, [monitorOptions, options.fromExternalData]);
|
||||
};
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { draggable } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||||
import type { dropTargetForExternal } from '@atlaskit/pragmatic-drag-and-drop/external/adapter';
|
||||
|
||||
export type { Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';
|
||||
|
||||
export interface DNDData<
|
||||
Draggable extends Record<string, unknown> = Record<string, unknown>,
|
||||
DropTarget extends Record<string, unknown> = Record<string, unknown>,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './observe-intersection';
|
||||
export * from './observe-resize';
|
||||
export * from './shallow-equal';
|
||||
export { startScopedViewTransition } from './view-transition';
|
||||
export * from './with-unit';
|
||||
|
||||
43
packages/frontend/component/src/utils/shallow-equal.ts
Normal file
43
packages/frontend/component/src/utils/shallow-equal.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// credit: https://github.com/facebook/fbjs/blob/main/packages/fbjs/src/core/shallowEqual.js
|
||||
export function shallowEqual(objA: any, objB: any) {
|
||||
if (Object.is(objA, objB)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof objA !== 'object' ||
|
||||
objA === null ||
|
||||
typeof objB !== 'object' ||
|
||||
objB === null
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const keysA = Object.keys(objA);
|
||||
const keysB = Object.keys(objB);
|
||||
|
||||
if (keysA.length !== keysB.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Test for A's keys different from B.
|
||||
for (const key of keysA) {
|
||||
if (
|
||||
!Object.prototype.hasOwnProperty.call(objB, key) ||
|
||||
!Object.is(objA[key], objB[key])
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export const shallowUpdater =
|
||||
<T>(newState: T) =>
|
||||
(state: T | null): T => {
|
||||
if (state && shallowEqual(state, newState)) {
|
||||
return state;
|
||||
}
|
||||
return newState;
|
||||
};
|
||||
@@ -20,9 +20,6 @@
|
||||
"@blocksuite/icons": "2.2.1",
|
||||
"@capacitor/app": "^6.0.2",
|
||||
"@capacitor/browser": "^6.0.4",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dotlottie/player-component": "^2.7.12",
|
||||
"@emotion/cache": "^11.14.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
|
||||
@@ -23,7 +23,9 @@ export const BlocksuiteEditorJournalDocTitle = ({ page }: { page: Blocks }) => {
|
||||
<div className="doc-title-container" data-testid="journal-title">
|
||||
<span data-testid="date">{localizedJournalDate}</span>
|
||||
{isTodayJournal ? (
|
||||
<span className={styles.titleTodayTag}>{t['com.affine.today']()}</span>
|
||||
<span className={styles.titleTodayTag} data-testid="date-today-label">
|
||||
{t['com.affine.today']()}
|
||||
</span>
|
||||
) : (
|
||||
<span className={styles.titleDayTag}>{day}</span>
|
||||
)}
|
||||
|
||||
@@ -8,7 +8,9 @@ export const JournalTodayButton = () => {
|
||||
const journalHelper = useJournalRouteHelper();
|
||||
|
||||
const onToday = useCallback(() => {
|
||||
journalHelper.openToday();
|
||||
journalHelper.openToday({
|
||||
replaceHistory: true,
|
||||
});
|
||||
}, [journalHelper]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
JournalService,
|
||||
type MaybeDate,
|
||||
} from '@affine/core/modules/journal';
|
||||
import type { WorkbenchOpenOptions } from '@affine/core/modules/workbench/entities/workbench';
|
||||
import { i18nTime } from '@affine/i18n';
|
||||
import { track } from '@affine/track';
|
||||
import { useService, useServices } from '@toeverything/infra';
|
||||
@@ -60,9 +61,9 @@ export const useJournalRouteHelper = () => {
|
||||
* open journal by date, create one if not exist
|
||||
*/
|
||||
const openJournal = useCallback(
|
||||
(maybeDate: MaybeDate, newTab?: boolean) => {
|
||||
(maybeDate: MaybeDate, options?: WorkbenchOpenOptions) => {
|
||||
const page = getJournalByDate(maybeDate);
|
||||
workbench.openDoc(page.id, { at: newTab ? 'new-tab' : 'active' });
|
||||
workbench.openDoc(page.id, options);
|
||||
track.$.navigationPanel.journal.navigate({
|
||||
to: 'journal',
|
||||
});
|
||||
@@ -75,9 +76,9 @@ export const useJournalRouteHelper = () => {
|
||||
* open today's journal
|
||||
*/
|
||||
const openToday = useCallback(
|
||||
(newTab?: boolean) => {
|
||||
(options: WorkbenchOpenOptions) => {
|
||||
const date = dayjs().format(JOURNAL_DATE_FORMAT);
|
||||
return openJournal(date, newTab);
|
||||
return openJournal(date, options);
|
||||
},
|
||||
[openJournal]
|
||||
);
|
||||
|
||||
@@ -180,7 +180,8 @@ export const PageListItem = (props: PageListItemProps) => {
|
||||
},
|
||||
},
|
||||
}),
|
||||
[props.draggable, props.pageId]
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[props.draggable, props.pageId, props.selectable]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
CompatibleFavoriteItemsAdapter,
|
||||
FavoriteService,
|
||||
} from '@affine/core/modules/favorite';
|
||||
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||
import { WorkspaceService } from '@affine/core/modules/workspace';
|
||||
import type { Collection, DeleteCollectionInfo } from '@affine/env/filter';
|
||||
@@ -65,19 +64,15 @@ export const PageOperationCell = ({
|
||||
}: PageOperationCellProps) => {
|
||||
const t = useI18n();
|
||||
const {
|
||||
featureFlagService,
|
||||
workspaceService,
|
||||
compatibleFavoriteItemsAdapter: favAdapter,
|
||||
workbenchService,
|
||||
} = useServices({
|
||||
FeatureFlagService,
|
||||
WorkspaceService,
|
||||
CompatibleFavoriteItemsAdapter,
|
||||
WorkbenchService,
|
||||
});
|
||||
const enableSplitView = useLiveData(
|
||||
featureFlagService.flags.enable_multi_view.$
|
||||
);
|
||||
|
||||
const currentWorkspace = workspaceService.workspace;
|
||||
const favourite = useLiveData(favAdapter.isFavorite$(page.id, 'doc'));
|
||||
const workbench = workbenchService.workbench;
|
||||
@@ -194,7 +189,7 @@ export const PageOperationCell = ({
|
||||
<MenuItem onClick={onOpenInNewTab} prefixIcon={<OpenInNewIcon />}>
|
||||
{t['com.affine.workbench.tab.page-menu-open']()}
|
||||
</MenuItem>
|
||||
{BUILD_CONFIG.isElectron && enableSplitView ? (
|
||||
{BUILD_CONFIG.isElectron ? (
|
||||
<MenuItem onClick={onOpenInSplitView} prefixIcon={<SplitViewIcon />}>
|
||||
{t['com.affine.workbench.split-view.page-menu-open']()}
|
||||
</MenuItem>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { shallowEqual } from '@affine/component';
|
||||
import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
|
||||
import type { Tag } from '@affine/env/filter';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
@@ -34,7 +35,6 @@ import type {
|
||||
TagListItemProps,
|
||||
TagMeta,
|
||||
} from './types';
|
||||
import { shallowEqual } from './utils';
|
||||
|
||||
export const ItemGroupHeader = memo(function ItemGroupHeader<
|
||||
T extends ListItem,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { shallowEqual } from '@affine/component';
|
||||
import { DEFAULT_SORT_KEY } from '@affine/env/constant';
|
||||
import { atom } from 'jotai';
|
||||
import { selectAtom } from 'jotai/utils';
|
||||
@@ -10,7 +11,6 @@ import type {
|
||||
MetaRecord,
|
||||
VirtualizedListProps,
|
||||
} from './types';
|
||||
import { shallowEqual } from './utils';
|
||||
|
||||
// for ease of use in the component tree
|
||||
// note: must use selectAtom to access this atom for efficiency
|
||||
|
||||
@@ -56,38 +56,3 @@ export const betweenDaysAgo = (
|
||||
): boolean => {
|
||||
return !withinDaysAgo(date, days0) && withinDaysAgo(date, days1);
|
||||
};
|
||||
|
||||
// credit: https://github.com/facebook/fbjs/blob/main/packages/fbjs/src/core/shallowEqual.js
|
||||
export function shallowEqual(objA: any, objB: any) {
|
||||
if (Object.is(objA, objB)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof objA !== 'object' ||
|
||||
objA === null ||
|
||||
typeof objB !== 'object' ||
|
||||
objB === null
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const keysA = Object.keys(objA);
|
||||
const keysB = Object.keys(objB);
|
||||
|
||||
if (keysA.length !== keysB.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Test for A's keys different from B.
|
||||
for (const key of keysA) {
|
||||
if (
|
||||
!Object.prototype.hasOwnProperty.call(objB, key) ||
|
||||
!Object.is(objA[key], objB[key])
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Menu, MenuItem, usePromptModal } from '@affine/component';
|
||||
import { useDeleteCollectionInfo } from '@affine/core/components/hooks/affine/use-delete-collection-info';
|
||||
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
||||
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
|
||||
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||
import type { Collection } from '@affine/env/filter';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
@@ -36,21 +35,16 @@ export const CollectionOperations = ({
|
||||
const {
|
||||
collectionService: service,
|
||||
workbenchService,
|
||||
featureFlagService,
|
||||
workspaceDialogService,
|
||||
} = useServices({
|
||||
CollectionService,
|
||||
WorkbenchService,
|
||||
FeatureFlagService,
|
||||
WorkspaceDialogService,
|
||||
});
|
||||
const deleteInfo = useDeleteCollectionInfo();
|
||||
const workbench = workbenchService.workbench;
|
||||
const t = useI18n();
|
||||
const { openPromptModal } = usePromptModal();
|
||||
const enableMultiView = useLiveData(
|
||||
featureFlagService.flags.enable_multi_view.$
|
||||
);
|
||||
|
||||
const showEditName = useCallback(() => {
|
||||
// use openRenameModal if it is in the sidebar collection list
|
||||
@@ -150,7 +144,7 @@ export const CollectionOperations = ({
|
||||
name: t['com.affine.workbench.tab.page-menu-open'](),
|
||||
click: openCollectionNewTab,
|
||||
},
|
||||
...(BUILD_CONFIG.isElectron && enableMultiView
|
||||
...(BUILD_CONFIG.isElectron
|
||||
? [
|
||||
{
|
||||
icon: <SplitViewIcon />,
|
||||
@@ -172,7 +166,6 @@ export const CollectionOperations = ({
|
||||
},
|
||||
],
|
||||
[
|
||||
enableMultiView,
|
||||
t,
|
||||
showEditName,
|
||||
showEdit,
|
||||
|
||||
@@ -1,32 +1,19 @@
|
||||
import { useCatchEventCallback } from '@affine/core/components/hooks/use-catch-event-hook';
|
||||
import {
|
||||
useJournalInfoHelper,
|
||||
useJournalRouteHelper,
|
||||
} from '@affine/core/components/hooks/use-journal';
|
||||
import { MenuItem } from '@affine/core/modules/app-sidebar/views';
|
||||
import { MenuLinkItem } from '@affine/core/modules/app-sidebar/views';
|
||||
import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
|
||||
import { JournalService } from '@affine/core/modules/journal';
|
||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||
import { isNewTabTrigger } from '@affine/core/utils';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { TodayIcon } from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { type MouseEvent } from 'react';
|
||||
|
||||
export const AppSidebarJournalButton = () => {
|
||||
const t = useI18n();
|
||||
const docDisplayMetaService = useService(DocDisplayMetaService);
|
||||
const journalService = useService(JournalService);
|
||||
const workbench = useService(WorkbenchService).workbench;
|
||||
const location = useLiveData(workbench.location$);
|
||||
const { openToday } = useJournalRouteHelper();
|
||||
const maybeDocId = location.pathname.split('/')[1];
|
||||
const { isJournal } = useJournalInfoHelper(maybeDocId);
|
||||
|
||||
const handleOpenToday = useCatchEventCallback(
|
||||
(e: MouseEvent) => {
|
||||
openToday(isNewTabTrigger(e));
|
||||
},
|
||||
[openToday]
|
||||
);
|
||||
const isJournal = !!useLiveData(journalService.journalDate$(maybeDocId));
|
||||
|
||||
const JournalIcon = useLiveData(
|
||||
docDisplayMetaService.icon$(maybeDocId, {
|
||||
@@ -36,14 +23,13 @@ export const AppSidebarJournalButton = () => {
|
||||
const Icon = isJournal ? JournalIcon : TodayIcon;
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
<MenuLinkItem
|
||||
data-testid="slider-bar-journals-button"
|
||||
active={isJournal}
|
||||
onClick={handleOpenToday}
|
||||
onAuxClick={handleOpenToday}
|
||||
to={'/journals'}
|
||||
icon={<Icon />}
|
||||
>
|
||||
{t['com.affine.journal.app-sidebar-title']()}
|
||||
</MenuItem>
|
||||
</MenuLinkItem>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -78,27 +78,25 @@ export const mainContainerStyle = style({
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
overflow: 'clip',
|
||||
maxWidth: '100%',
|
||||
|
||||
selectors: {
|
||||
'&[data-client-border="true"]': {
|
||||
borderRadius: 6,
|
||||
margin: '8px',
|
||||
overflow: 'clip',
|
||||
padding: '8px',
|
||||
'@media': {
|
||||
print: {
|
||||
overflow: 'visible',
|
||||
margin: '0px',
|
||||
padding: '0px',
|
||||
borderRadius: '0px',
|
||||
},
|
||||
},
|
||||
},
|
||||
'&[data-client-border="true"][data-side-bar-open="true"]': {
|
||||
marginLeft: 0,
|
||||
paddingLeft: 0,
|
||||
},
|
||||
'&[data-client-border="true"][data-is-desktop="true"]': {
|
||||
marginTop: 0,
|
||||
paddingTop: 0,
|
||||
},
|
||||
'&[data-client-border="false"][data-is-desktop="true"][data-side-bar-open="true"]':
|
||||
{
|
||||
|
||||
11
packages/frontend/core/src/desktop/pages/journals/index.tsx
Normal file
11
packages/frontend/core/src/desktop/pages/journals/index.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { useJournalRouteHelper } from '@affine/core/components/hooks/use-journal';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
// this route page acts as a redirector to today's journal
|
||||
export const Component = () => {
|
||||
const { openToday } = useJournalRouteHelper();
|
||||
useEffect(() => {
|
||||
openToday({ replaceHistory: true });
|
||||
}, [openToday]);
|
||||
return null;
|
||||
};
|
||||
@@ -33,6 +33,10 @@ export const workbenchRoutes = [
|
||||
path: '/:pageId/attachments/:attachmentId',
|
||||
lazy: () => import('./pages/workspace/attachment/index'),
|
||||
},
|
||||
{
|
||||
path: '/journals',
|
||||
lazy: () => import('./pages/journals'),
|
||||
},
|
||||
{
|
||||
path: '*',
|
||||
lazy: () => import('./pages/404'),
|
||||
|
||||
@@ -11,7 +11,6 @@ import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
|
||||
import { CollectionService } from '@affine/core/modules/collection';
|
||||
import type { NodeOperation } from '@affine/core/modules/explorer';
|
||||
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
|
||||
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||
import { WorkspaceService } from '@affine/core/modules/workspace';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
@@ -161,10 +160,6 @@ export const useExplorerCollectionNodeOperationsMenu = (
|
||||
onOpenEdit: () => void
|
||||
): NodeOperation[] => {
|
||||
const t = useI18n();
|
||||
const { featureFlagService } = useServices({ FeatureFlagService });
|
||||
const enableMultiView = useLiveData(
|
||||
featureFlagService.flags.enable_multi_view.$
|
||||
);
|
||||
|
||||
const {
|
||||
favorite,
|
||||
@@ -246,7 +241,7 @@ export const useExplorerCollectionNodeOperationsMenu = (
|
||||
</MenuItem>
|
||||
),
|
||||
},
|
||||
...(BUILD_CONFIG.isElectron && enableMultiView
|
||||
...(BUILD_CONFIG.isElectron
|
||||
? [
|
||||
{
|
||||
index: 99,
|
||||
@@ -279,7 +274,6 @@ export const useExplorerCollectionNodeOperationsMenu = (
|
||||
},
|
||||
],
|
||||
[
|
||||
enableMultiView,
|
||||
favorite,
|
||||
handleAddDocToCollection,
|
||||
handleDeleteCollection,
|
||||
|
||||
@@ -13,7 +13,6 @@ import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
|
||||
import { DocsService } from '@affine/core/modules/doc';
|
||||
import type { NodeOperation } from '@affine/core/modules/explorer';
|
||||
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
|
||||
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||
import { WorkspaceService } from '@affine/core/modules/workspace';
|
||||
import { preventDefault } from '@affine/core/utils';
|
||||
@@ -171,7 +170,6 @@ export const useExplorerDocNodeOperationsMenu = (
|
||||
}
|
||||
): NodeOperation[] => {
|
||||
const t = useI18n();
|
||||
const featureFlagService = useService(FeatureFlagService);
|
||||
const {
|
||||
favorite,
|
||||
handleAddLinkedPage,
|
||||
@@ -186,9 +184,6 @@ export const useExplorerDocNodeOperationsMenu = (
|
||||
const docService = useService(DocsService);
|
||||
const docRecord = useLiveData(docService.list.doc$(docId));
|
||||
const title = useLiveData(docRecord?.title$);
|
||||
const enableMultiView = useLiveData(
|
||||
featureFlagService.flags.enable_multi_view.$
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
@@ -258,7 +253,7 @@ export const useExplorerDocNodeOperationsMenu = (
|
||||
</MenuItem>
|
||||
),
|
||||
},
|
||||
...(BUILD_CONFIG.isElectron && enableMultiView
|
||||
...(BUILD_CONFIG.isElectron
|
||||
? [
|
||||
{
|
||||
index: 100,
|
||||
@@ -305,7 +300,6 @@ export const useExplorerDocNodeOperationsMenu = (
|
||||
],
|
||||
[
|
||||
docId,
|
||||
enableMultiView,
|
||||
favorite,
|
||||
handleAddLinkedPage,
|
||||
handleDuplicate,
|
||||
|
||||
@@ -11,7 +11,6 @@ import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
||||
import { DocsService } from '@affine/core/modules/doc';
|
||||
import type { NodeOperation } from '@affine/core/modules/explorer';
|
||||
import { FavoriteService } from '@affine/core/modules/favorite';
|
||||
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
import { GlobalCacheService } from '@affine/core/modules/storage';
|
||||
import { TagService } from '@affine/core/modules/tag';
|
||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||
@@ -24,7 +23,7 @@ import {
|
||||
PlusIcon,
|
||||
SplitViewIcon,
|
||||
} from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useService, useServices } from '@toeverything/infra';
|
||||
import { useLiveData, useServices } from '@toeverything/infra';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { TagRenameSubMenu } from './dialog';
|
||||
@@ -219,10 +218,6 @@ export const useExplorerTagNodeOperationsMenu = (
|
||||
}
|
||||
): NodeOperation[] => {
|
||||
const t = useI18n();
|
||||
const featureFlagService = useService(FeatureFlagService);
|
||||
const enableMultiView = useLiveData(
|
||||
featureFlagService.flags.enable_multi_view.$
|
||||
);
|
||||
const {
|
||||
favorite,
|
||||
handleNewDoc,
|
||||
@@ -266,7 +261,7 @@ export const useExplorerTagNodeOperationsMenu = (
|
||||
</MenuItem>
|
||||
),
|
||||
},
|
||||
...(BUILD_CONFIG.isElectron && enableMultiView
|
||||
...(BUILD_CONFIG.isElectron
|
||||
? [
|
||||
{
|
||||
index: 100,
|
||||
@@ -312,7 +307,6 @@ export const useExplorerTagNodeOperationsMenu = (
|
||||
},
|
||||
],
|
||||
[
|
||||
enableMultiView,
|
||||
favorite,
|
||||
handleChangeNameOrColor,
|
||||
handleMoveToTrash,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Skeleton } from '@affine/component';
|
||||
import { type DropTargetGetFeedback, Skeleton } from '@affine/component';
|
||||
import { ResizePanel } from '@affine/component/resize-panel';
|
||||
import { useAppSettingHelper } from '@affine/core/components/hooks/affine/use-app-setting-helper';
|
||||
import { NavigateContext } from '@affine/core/components/hooks/use-navigate-helper';
|
||||
import { WorkspaceNavigator } from '@affine/core/components/workspace-selector';
|
||||
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import {
|
||||
useLiveData,
|
||||
@@ -14,6 +15,8 @@ import { debounce } from 'lodash-es';
|
||||
import type { PropsWithChildren, ReactElement } from 'react';
|
||||
import { useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { WorkbenchService } from '../../workbench';
|
||||
import { allowedSplitViewEntityTypes } from '../../workbench/view/split-view/types';
|
||||
import { WorkspaceService } from '../../workspace';
|
||||
import { AppSidebarService } from '../services/app-sidebar';
|
||||
import * as styles from './fallback.css';
|
||||
@@ -44,6 +47,7 @@ export function AppSidebar({ children }: PropsWithChildren) {
|
||||
const clientBorder = appSettings.clientBorder;
|
||||
|
||||
const appSidebarService = useService(AppSidebarService).sidebar;
|
||||
const workbenchService = useService(WorkbenchService).workbench;
|
||||
|
||||
const open = useLiveData(appSidebarService.open$);
|
||||
const width = useLiveData(appSidebarService.width$);
|
||||
@@ -147,6 +151,31 @@ export function AppSidebar({ children }: PropsWithChildren) {
|
||||
};
|
||||
}, [appSidebarService, resizing, sidebarState, width]);
|
||||
|
||||
const resizeHandleDropTargetOptions = useMemo(() => {
|
||||
return () => ({
|
||||
data: () => {
|
||||
const firstView = workbenchService.views$.value.at(0);
|
||||
|
||||
if (!firstView) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
at: 'workbench:resize-handle',
|
||||
edge: 'left', // left of the first view
|
||||
viewId: firstView.id,
|
||||
};
|
||||
},
|
||||
canDrop: (data: DropTargetGetFeedback<AffineDNDData>) => {
|
||||
return (
|
||||
(!!data.source.data.entity?.type &&
|
||||
allowedSplitViewEntityTypes.has(data.source.data.entity?.type)) ||
|
||||
data.source.data.from?.at === 'workbench:link'
|
||||
);
|
||||
},
|
||||
});
|
||||
}, [workbenchService.views$.value]);
|
||||
|
||||
if (!initialized) {
|
||||
return null;
|
||||
}
|
||||
@@ -154,6 +183,7 @@ export function AppSidebar({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<>
|
||||
<ResizePanel
|
||||
resizeHandleDropTargetOptions={resizeHandleDropTargetOptions}
|
||||
floating={
|
||||
sidebarState === 'floating' || sidebarState === 'floating-with-mask'
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
type MouseEventHandler,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
@@ -62,71 +63,24 @@ const tabCanDrop =
|
||||
return false;
|
||||
};
|
||||
|
||||
const WorkbenchTab = ({
|
||||
const WorkbenchView = ({
|
||||
workbench,
|
||||
active: tabActive,
|
||||
view,
|
||||
activeViewIndex,
|
||||
tabsLength,
|
||||
dnd,
|
||||
onDrop,
|
||||
viewIdx,
|
||||
tabActive,
|
||||
}: {
|
||||
workbench: TabStatus;
|
||||
active: boolean;
|
||||
view: TabStatus['views'][number];
|
||||
activeViewIndex: number;
|
||||
tabsLength: number;
|
||||
viewIdx: number;
|
||||
tabActive: boolean;
|
||||
dnd?: boolean;
|
||||
onDrop?: (data: DropTargetDropEvent<AffineDNDData>) => void;
|
||||
}) => {
|
||||
useServiceOptional(DesktopStateSynchronizer);
|
||||
const tabsHeaderService = useService(AppTabsHeaderService);
|
||||
const activeViewIndex = workbench.activeViewIndex ?? 0;
|
||||
const onContextMenu = useAsyncCallback(
|
||||
async (viewIdx: number) => {
|
||||
const action = await tabsHeaderService.showContextMenu?.(
|
||||
workbench.id,
|
||||
viewIdx
|
||||
);
|
||||
switch (action?.type) {
|
||||
case 'open-in-split-view': {
|
||||
track.$.appTabsHeader.$.tabAction({
|
||||
control: 'contextMenu',
|
||||
action: 'openInSplitView',
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'separate-view': {
|
||||
track.$.appTabsHeader.$.tabAction({
|
||||
control: 'contextMenu',
|
||||
action: 'separateTabs',
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'pin-tab': {
|
||||
if (action.payload.shouldPin) {
|
||||
track.$.appTabsHeader.$.tabAction({
|
||||
control: 'contextMenu',
|
||||
action: 'pin',
|
||||
});
|
||||
} else {
|
||||
track.$.appTabsHeader.$.tabAction({
|
||||
control: 'contextMenu',
|
||||
action: 'unpin',
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
// fixme: when close tab the view may already be gc'ed
|
||||
case 'close-tab': {
|
||||
track.$.appTabsHeader.$.tabAction({
|
||||
control: 'contextMenu',
|
||||
action: 'close',
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
[tabsHeaderService, workbench.id]
|
||||
);
|
||||
|
||||
const onActivateView = useAsyncCallback(
|
||||
async (viewIdx: number) => {
|
||||
if (viewIdx === activeViewIndex && tabActive) {
|
||||
@@ -147,6 +101,15 @@ const WorkbenchTab = ({
|
||||
},
|
||||
[activeViewIndex, tabActive, tabsHeaderService, workbench.id]
|
||||
);
|
||||
|
||||
const handleClick: MouseEventHandler = useCatchEventCallback(
|
||||
async e => {
|
||||
e.stopPropagation();
|
||||
onActivateView(viewIdx);
|
||||
},
|
||||
[onActivateView, viewIdx]
|
||||
);
|
||||
|
||||
const handleAuxClick: MouseEventHandler = useCatchEventCallback(
|
||||
async e => {
|
||||
if (e.button === 1) {
|
||||
@@ -160,6 +123,106 @@ const WorkbenchTab = ({
|
||||
[tabsHeaderService, workbench.id]
|
||||
);
|
||||
|
||||
const onContextMenu = useAsyncCallback(async () => {
|
||||
const action = await tabsHeaderService.showContextMenu?.(
|
||||
workbench.id,
|
||||
viewIdx
|
||||
);
|
||||
switch (action?.type) {
|
||||
case 'open-in-split-view': {
|
||||
track.$.appTabsHeader.$.tabAction({
|
||||
control: 'contextMenu',
|
||||
action: 'openInSplitView',
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'separate-view': {
|
||||
track.$.appTabsHeader.$.tabAction({
|
||||
control: 'contextMenu',
|
||||
action: 'separateTabs',
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'pin-tab': {
|
||||
if (action.payload.shouldPin) {
|
||||
track.$.appTabsHeader.$.tabAction({
|
||||
control: 'contextMenu',
|
||||
action: 'pin',
|
||||
});
|
||||
} else {
|
||||
track.$.appTabsHeader.$.tabAction({
|
||||
control: 'contextMenu',
|
||||
action: 'unpin',
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
// fixme: when close tab the view may already be gc'ed
|
||||
case 'close-tab': {
|
||||
track.$.appTabsHeader.$.tabAction({
|
||||
control: 'contextMenu',
|
||||
action: 'close',
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}, [tabsHeaderService, viewIdx, workbench.id]);
|
||||
|
||||
const contentNode = useMemo(() => {
|
||||
return (
|
||||
<>
|
||||
<div className={styles.labelIcon}>
|
||||
{workbench.ready || !workbench.loaded ? (
|
||||
iconNameToIcon[view.iconName ?? 'allDocs']
|
||||
) : (
|
||||
<Loading />
|
||||
)}
|
||||
</div>
|
||||
{!view.title ? null : (
|
||||
<div
|
||||
title={view.title}
|
||||
className={styles.splitViewLabelText}
|
||||
data-padding-right={tabsLength > 1 && !workbench.pinned}
|
||||
>
|
||||
{view.title}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}, [workbench, view, tabsLength]);
|
||||
return (
|
||||
<button
|
||||
data-testid="split-view-label"
|
||||
className={styles.splitViewLabel}
|
||||
data-active={activeViewIndex === viewIdx && tabActive}
|
||||
onContextMenu={onContextMenu}
|
||||
onAuxClick={handleAuxClick}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{contentNode}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const WorkbenchTab = ({
|
||||
workbench,
|
||||
active: tabActive,
|
||||
tabsLength,
|
||||
dnd,
|
||||
onDrop,
|
||||
}: {
|
||||
workbench: TabStatus;
|
||||
active: boolean;
|
||||
tabsLength: number;
|
||||
dnd?: boolean;
|
||||
onDrop?: (data: DropTargetDropEvent<AffineDNDData>) => void;
|
||||
}) => {
|
||||
useServiceOptional(DesktopStateSynchronizer);
|
||||
const tabsHeaderService = useService(AppTabsHeaderService);
|
||||
const activeViewIndex = workbench.activeViewIndex ?? 0;
|
||||
|
||||
const handleCloseTab = useCatchEventCallback(async () => {
|
||||
await tabsHeaderService.closeTab?.(workbench.id);
|
||||
track.$.appTabsHeader.$.tabAction({
|
||||
@@ -175,11 +238,11 @@ const WorkbenchTab = ({
|
||||
},
|
||||
onDrop,
|
||||
dropEffect: 'move',
|
||||
canDrop: tabCanDrop(workbench),
|
||||
canDrop: dnd ? tabCanDrop(workbench) : false,
|
||||
isSticky: true,
|
||||
allowExternal: true,
|
||||
}),
|
||||
[onDrop, workbench]
|
||||
[dnd, onDrop, workbench]
|
||||
);
|
||||
|
||||
const { dragRef } = useDraggable<AffineDNDData>(() => {
|
||||
@@ -251,37 +314,15 @@ const WorkbenchTab = ({
|
||||
{workbench.views.map((view, viewIdx) => {
|
||||
return (
|
||||
<Fragment key={view.id}>
|
||||
<button
|
||||
key={view.id}
|
||||
data-testid="split-view-label"
|
||||
className={styles.splitViewLabel}
|
||||
data-active={activeViewIndex === viewIdx && tabActive}
|
||||
onContextMenu={() => {
|
||||
onContextMenu(viewIdx);
|
||||
}}
|
||||
onAuxClick={handleAuxClick}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onActivateView(viewIdx);
|
||||
}}
|
||||
>
|
||||
<div className={styles.labelIcon}>
|
||||
{workbench.ready || !workbench.loaded ? (
|
||||
iconNameToIcon[view.iconName ?? 'allDocs']
|
||||
) : (
|
||||
<Loading />
|
||||
)}
|
||||
</div>
|
||||
{!view.title ? null : (
|
||||
<div
|
||||
title={view.title}
|
||||
className={styles.splitViewLabelText}
|
||||
data-padding-right={tabsLength > 1 && !workbench.pinned}
|
||||
>
|
||||
{view.title}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<WorkbenchView
|
||||
workbench={workbench}
|
||||
view={view}
|
||||
activeViewIndex={activeViewIndex}
|
||||
tabsLength={workbench.views.length}
|
||||
viewIdx={viewIdx}
|
||||
tabActive={tabActive}
|
||||
dnd={dnd}
|
||||
/>
|
||||
|
||||
{viewIdx !== workbench.views.length - 1 ? (
|
||||
<div className={styles.splitViewSeparator} />
|
||||
|
||||
@@ -81,7 +81,7 @@ export class DndService extends Service {
|
||||
isDropEvent?: boolean
|
||||
) => {
|
||||
if (!isDropEvent) {
|
||||
return {};
|
||||
return this.resolveBlocksuiteExternalData(args.source) || {};
|
||||
}
|
||||
|
||||
let resolved: AffineDNDData['draggable'] | null = null;
|
||||
@@ -168,24 +168,30 @@ export class DndService extends Service {
|
||||
if (!dndAPI) {
|
||||
return null;
|
||||
}
|
||||
const encoded = source.getStringData(dndAPI.mimeType);
|
||||
if (!encoded) {
|
||||
return null;
|
||||
}
|
||||
const snapshot = dndAPI.decodeSnapshot(encoded);
|
||||
if (!snapshot) {
|
||||
return null;
|
||||
}
|
||||
const entity = this.resolveBlockSnapshot(snapshot);
|
||||
if (!entity) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
entity,
|
||||
from: {
|
||||
|
||||
if (source.types.includes(dndAPI.mimeType)) {
|
||||
const from = {
|
||||
at: 'blocksuite-editor',
|
||||
},
|
||||
};
|
||||
} as const;
|
||||
|
||||
let entity: Entity | null = null;
|
||||
|
||||
const encoded = source.getStringData(dndAPI.mimeType);
|
||||
const snapshot = encoded ? dndAPI.decodeSnapshot(encoded) : null;
|
||||
entity = snapshot ? this.resolveBlockSnapshot(snapshot) : null;
|
||||
|
||||
if (!entity) {
|
||||
return {
|
||||
from,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
entity,
|
||||
from,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
private readonly resolveHTML: EntityResolver = html => {
|
||||
|
||||
@@ -9,7 +9,6 @@ import { useDeleteCollectionInfo } from '@affine/core/components/hooks/affine/us
|
||||
import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
|
||||
import { CollectionService } from '@affine/core/modules/collection';
|
||||
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
|
||||
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||
import { WorkspaceService } from '@affine/core/modules/workspace';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
@@ -37,13 +36,11 @@ export const useExplorerCollectionNodeOperations = (
|
||||
workspaceService,
|
||||
collectionService,
|
||||
compatibleFavoriteItemsAdapter,
|
||||
featureFlagService,
|
||||
} = useServices({
|
||||
WorkbenchService,
|
||||
WorkspaceService,
|
||||
CollectionService,
|
||||
CompatibleFavoriteItemsAdapter,
|
||||
FeatureFlagService,
|
||||
});
|
||||
const deleteInfo = useDeleteCollectionInfo();
|
||||
|
||||
@@ -51,9 +48,6 @@ export const useExplorerCollectionNodeOperations = (
|
||||
workspaceService.workspace.docCollection
|
||||
);
|
||||
|
||||
const enableMultiView = useLiveData(
|
||||
featureFlagService.flags.enable_multi_view.$
|
||||
);
|
||||
const favorite = useLiveData(
|
||||
useMemo(
|
||||
() =>
|
||||
@@ -173,7 +167,7 @@ export const useExplorerCollectionNodeOperations = (
|
||||
</MenuItem>
|
||||
),
|
||||
},
|
||||
...(BUILD_CONFIG.isElectron && enableMultiView
|
||||
...(BUILD_CONFIG.isElectron
|
||||
? [
|
||||
{
|
||||
index: 99,
|
||||
@@ -206,7 +200,6 @@ export const useExplorerCollectionNodeOperations = (
|
||||
},
|
||||
],
|
||||
[
|
||||
enableMultiView,
|
||||
favorite,
|
||||
handleAddDocToCollection,
|
||||
handleDeleteCollection,
|
||||
|
||||
@@ -11,7 +11,6 @@ import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hoo
|
||||
import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
|
||||
import { DocsService } from '@affine/core/modules/doc';
|
||||
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
|
||||
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||
import { WorkspaceService } from '@affine/core/modules/workspace';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
@@ -43,17 +42,12 @@ export const useExplorerDocNodeOperations = (
|
||||
workspaceService,
|
||||
docsService,
|
||||
compatibleFavoriteItemsAdapter,
|
||||
featureFlagService,
|
||||
} = useServices({
|
||||
DocsService,
|
||||
WorkbenchService,
|
||||
WorkspaceService,
|
||||
CompatibleFavoriteItemsAdapter,
|
||||
FeatureFlagService,
|
||||
});
|
||||
const enableMultiView = useLiveData(
|
||||
featureFlagService.flags.enable_multi_view.$
|
||||
);
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
|
||||
const docRecord = useLiveData(docsService.list.doc$(docId));
|
||||
@@ -188,7 +182,7 @@ export const useExplorerDocNodeOperations = (
|
||||
</MenuItem>
|
||||
),
|
||||
},
|
||||
...(BUILD_CONFIG.isElectron && enableMultiView
|
||||
...(BUILD_CONFIG.isElectron
|
||||
? [
|
||||
{
|
||||
index: 100,
|
||||
@@ -234,7 +228,6 @@ export const useExplorerDocNodeOperations = (
|
||||
},
|
||||
],
|
||||
[
|
||||
enableMultiView,
|
||||
favorite,
|
||||
handleAddLinkedPage,
|
||||
handleDuplicate,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { usePageHelper } from '@affine/core/components/blocksuite/block-suite-pa
|
||||
import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
|
||||
import { DocsService } from '@affine/core/modules/doc';
|
||||
import { FavoriteService } from '@affine/core/modules/favorite';
|
||||
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
import { TagService } from '@affine/core/modules/tag';
|
||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||
import { WorkspaceService } from '@affine/core/modules/workspace';
|
||||
@@ -29,28 +28,19 @@ export const useExplorerTagNodeOperations = (
|
||||
}
|
||||
): NodeOperation[] => {
|
||||
const t = useI18n();
|
||||
const {
|
||||
workbenchService,
|
||||
workspaceService,
|
||||
tagService,
|
||||
favoriteService,
|
||||
featureFlagService,
|
||||
} = useServices({
|
||||
WorkbenchService,
|
||||
WorkspaceService,
|
||||
TagService,
|
||||
DocsService,
|
||||
FavoriteService,
|
||||
FeatureFlagService,
|
||||
});
|
||||
const { workbenchService, workspaceService, tagService, favoriteService } =
|
||||
useServices({
|
||||
WorkbenchService,
|
||||
WorkspaceService,
|
||||
TagService,
|
||||
DocsService,
|
||||
FavoriteService,
|
||||
});
|
||||
|
||||
const favorite = useLiveData(
|
||||
favoriteService.favoriteList.favorite$('tag', tagId)
|
||||
);
|
||||
const tagRecord = useLiveData(tagService.tagList.tagByTagId$(tagId));
|
||||
const enableMultiView = useLiveData(
|
||||
featureFlagService.flags.enable_multi_view.$
|
||||
);
|
||||
|
||||
const { createPage } = usePageHelper(
|
||||
workspaceService.workspace.docCollection
|
||||
@@ -115,7 +105,7 @@ export const useExplorerTagNodeOperations = (
|
||||
</MenuItem>
|
||||
),
|
||||
},
|
||||
...(BUILD_CONFIG.isElectron && enableMultiView
|
||||
...(BUILD_CONFIG.isElectron
|
||||
? [
|
||||
{
|
||||
index: 100,
|
||||
@@ -161,7 +151,6 @@ export const useExplorerTagNodeOperations = (
|
||||
},
|
||||
],
|
||||
[
|
||||
enableMultiView,
|
||||
favorite,
|
||||
handleMoveToTrash,
|
||||
handleNewDoc,
|
||||
|
||||
@@ -103,18 +103,6 @@ export const AFFINE_FLAGS = {
|
||||
configurable: false,
|
||||
defaultState: true,
|
||||
},
|
||||
enable_multi_view: {
|
||||
category: 'affine',
|
||||
displayName:
|
||||
'com.affine.settings.workspace.experimental-features.enable-multi-view.name',
|
||||
description:
|
||||
'com.affine.settings.workspace.experimental-features.enable-multi-view.description',
|
||||
feedbackType: 'discord',
|
||||
feedbackLink:
|
||||
'https://discord.com/channels/959027316334407691/1280009690004324405',
|
||||
configurable: isDesktopEnvironment,
|
||||
defaultState: isCanaryBuild,
|
||||
},
|
||||
enable_emoji_folder_icon: {
|
||||
category: 'affine',
|
||||
displayName:
|
||||
|
||||
@@ -12,7 +12,7 @@ import { View } from './view';
|
||||
|
||||
export type WorkbenchPosition = 'beside' | 'active' | 'head' | 'tail' | number;
|
||||
|
||||
type WorkbenchOpenOptions = {
|
||||
export type WorkbenchOpenOptions = {
|
||||
at?: WorkbenchPosition | 'new-tab';
|
||||
replaceHistory?: boolean;
|
||||
show?: boolean; // only for new tab
|
||||
@@ -52,16 +52,24 @@ export class Workbench extends Entity {
|
||||
});
|
||||
sidebarOpen$ = new LiveData(false);
|
||||
|
||||
active(index: number) {
|
||||
index = Math.max(0, Math.min(index, this.views$.value.length - 1));
|
||||
this.activeViewIndex$.next(index);
|
||||
active(index: number | View) {
|
||||
if (typeof index === 'number') {
|
||||
index = Math.max(0, Math.min(index, this.views$.value.length - 1));
|
||||
this.activeViewIndex$.next(index);
|
||||
} else {
|
||||
this.activeViewIndex$.next(this.views$.value.indexOf(index));
|
||||
}
|
||||
}
|
||||
|
||||
updateBasename(basename: string) {
|
||||
this.basename$.next(basename);
|
||||
}
|
||||
|
||||
createView(at: WorkbenchPosition = 'beside', defaultLocation: To) {
|
||||
createView(
|
||||
at: WorkbenchPosition = 'beside',
|
||||
defaultLocation: To,
|
||||
active = true
|
||||
) {
|
||||
const view = this.framework.createEntity(View, {
|
||||
id: nanoid(),
|
||||
defaultLocation,
|
||||
@@ -70,7 +78,9 @@ export class Workbench extends Entity {
|
||||
newViews.splice(this.indexAt(at), 0, view);
|
||||
this.views$.next(newViews);
|
||||
const index = newViews.indexOf(view);
|
||||
this.active(index);
|
||||
if (active) {
|
||||
this.active(index);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
@@ -95,7 +105,7 @@ export class Workbench extends Entity {
|
||||
const { at = 'active', replaceHistory = false } = option;
|
||||
let view = this.viewAt(at);
|
||||
if (!view) {
|
||||
const newIndex = this.createView(at, to);
|
||||
const newIndex = this.createView(at, to, option.show);
|
||||
view = this.viewAt(newIndex);
|
||||
if (!view) {
|
||||
throw new Unreachable();
|
||||
|
||||
@@ -28,12 +28,9 @@ export class DesktopStateSynchronizer extends Service {
|
||||
event.type === 'open-in-split-view' &&
|
||||
event.payload.tabId === appInfo?.viewId
|
||||
) {
|
||||
const to =
|
||||
event.payload.view?.path ??
|
||||
workbench.activeView$.value?.location$.value;
|
||||
|
||||
workbench.open(to, {
|
||||
workbench.openAll({
|
||||
at: 'beside',
|
||||
show: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -23,34 +23,57 @@ export const menuTrigger = style({
|
||||
height: 0,
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
|
||||
export const indicator = style({
|
||||
width: 29,
|
||||
padding: '6px 20px',
|
||||
height: 15,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 3,
|
||||
cursor: 'grab',
|
||||
['WebkitAppRegion' as string]: 'no-drag',
|
||||
color: cssVar('placeholderColor'),
|
||||
transition: 'all 0.2s',
|
||||
|
||||
selectors: {
|
||||
'&:hover, &:active, &[data-active="true"]': {
|
||||
'&:hover, &[data-active="true"], &[data-dragging="true"]': {
|
||||
color: cssVar('brandColor'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const indicatorInner = style({
|
||||
width: 16,
|
||||
height: 3,
|
||||
borderRadius: 10,
|
||||
backgroundColor: 'currentColor',
|
||||
transition: 'all 0.5s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
|
||||
selectors: {
|
||||
'[data-is-dragging="true"] &': {
|
||||
width: 24,
|
||||
height: 2,
|
||||
'&[data-dragging="true"]': {
|
||||
gap: 4,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const indicatorDot = style({
|
||||
width: 4,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: 'currentColor',
|
||||
transition: 'all 0.2s',
|
||||
selectors: {
|
||||
[`${indicator}[data-dragging="true"] &:is([data-idx="0"], [data-idx="2"])`]:
|
||||
{
|
||||
width: 7,
|
||||
},
|
||||
[`${indicator}[data-dragging="true"] &[data-idx="1"]`]: {
|
||||
width: 6,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const indicatorGradient = style({
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
height: '2px',
|
||||
background:
|
||||
'linear-gradient(to right, transparent, currentColor, transparent)',
|
||||
transition: 'opacity 0.2s',
|
||||
opacity: 0,
|
||||
selectors: {
|
||||
[`${indicator}[data-dragging="true"] &, ${indicator}:hover &`]: {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4,41 +4,23 @@ import clsx from 'clsx';
|
||||
import type { HTMLAttributes, MouseEventHandler } from 'react';
|
||||
import { forwardRef, memo, useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import type { View } from '../../entities/view';
|
||||
import * as styles from './indicator.css';
|
||||
|
||||
export interface SplitViewMenuProps extends HTMLAttributes<HTMLDivElement> {
|
||||
export interface SplitViewDragHandleProps
|
||||
extends HTMLAttributes<HTMLDivElement> {
|
||||
active?: boolean;
|
||||
dragging?: boolean;
|
||||
open?: boolean;
|
||||
onOpenMenu?: () => void;
|
||||
setPressed: (v: boolean) => void;
|
||||
}
|
||||
|
||||
export const SplitViewMenuIndicator = memo(
|
||||
forwardRef<HTMLDivElement, SplitViewMenuProps>(
|
||||
function SplitViewMenuIndicator(
|
||||
{
|
||||
className,
|
||||
active,
|
||||
open,
|
||||
setPressed,
|
||||
onOpenMenu,
|
||||
...attrs
|
||||
}: SplitViewMenuProps,
|
||||
export const SplitViewDragHandle = memo(
|
||||
forwardRef<HTMLDivElement, SplitViewDragHandleProps>(
|
||||
function SplitViewDragHandle(
|
||||
{ className, active, open, onOpenMenu, dragging, ...attrs },
|
||||
ref
|
||||
) {
|
||||
// dnd's `isDragging` changes after mouseDown and mouseMoved
|
||||
const onMouseDown = useCallback(() => {
|
||||
const t = setTimeout(() => setPressed(true), 100);
|
||||
window.addEventListener(
|
||||
'mouseup',
|
||||
() => {
|
||||
clearTimeout(t);
|
||||
setPressed(false);
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
}, [setPressed]);
|
||||
|
||||
const onClick: MouseEventHandler = useCallback(() => {
|
||||
!open && onOpenMenu?.();
|
||||
}, [onOpenMenu, open]);
|
||||
@@ -47,13 +29,16 @@ export const SplitViewMenuIndicator = memo(
|
||||
<div
|
||||
ref={ref}
|
||||
data-active={active}
|
||||
data-dragging={dragging}
|
||||
data-testid="split-view-indicator"
|
||||
className={clsx(className, styles.indicator)}
|
||||
onClick={onClick}
|
||||
onMouseDown={onMouseDown}
|
||||
{...attrs}
|
||||
>
|
||||
<div className={styles.indicatorInner} />
|
||||
<div className={styles.indicatorGradient} />
|
||||
<div data-idx={0} className={styles.indicatorDot} />
|
||||
<div data-idx={1} className={styles.indicatorDot} />
|
||||
<div data-idx={2} className={styles.indicatorDot} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -61,64 +46,69 @@ export const SplitViewMenuIndicator = memo(
|
||||
);
|
||||
|
||||
interface SplitViewIndicatorProps extends HTMLAttributes<HTMLDivElement> {
|
||||
isDragging?: boolean;
|
||||
view: View;
|
||||
isActive?: boolean;
|
||||
isDragging?: boolean;
|
||||
menuItems?: React.ReactNode;
|
||||
// import type { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities' is not allowed
|
||||
listeners?: any;
|
||||
setPressed?: (pressed: boolean) => void;
|
||||
dragHandleRef?: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
export const SplitViewIndicator = ({
|
||||
isDragging,
|
||||
isActive,
|
||||
menuItems,
|
||||
listeners,
|
||||
setPressed,
|
||||
}: SplitViewIndicatorProps) => {
|
||||
const active = isActive || isDragging;
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
export const SplitViewIndicator = memo(
|
||||
forwardRef<HTMLDivElement, SplitViewIndicatorProps>(
|
||||
function SplitViewIndicator(
|
||||
{ isActive, menuItems, isDragging, dragHandleRef },
|
||||
ref
|
||||
) {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
// prevent menu from opening when dragging
|
||||
const setOpenMenuManually = useCallback((open: boolean) => {
|
||||
if (open) return;
|
||||
setMenuOpen(open);
|
||||
}, []);
|
||||
const openMenu = useCallback(() => {
|
||||
setMenuOpen(true);
|
||||
}, []);
|
||||
// prevent menu from opening when dragging
|
||||
const setOpenMenuManually = useCallback((open: boolean) => {
|
||||
if (open) return;
|
||||
setMenuOpen(open);
|
||||
}, []);
|
||||
|
||||
const menuRootOptions = useMemo(
|
||||
() =>
|
||||
({
|
||||
open: menuOpen,
|
||||
onOpenChange: setOpenMenuManually,
|
||||
}) satisfies MenuProps['rootOptions'],
|
||||
[menuOpen, setOpenMenuManually]
|
||||
);
|
||||
const menuContentOptions = useMemo(
|
||||
() =>
|
||||
({
|
||||
align: 'center',
|
||||
}) satisfies MenuProps['contentOptions'],
|
||||
[]
|
||||
);
|
||||
const openMenu = useCallback(() => {
|
||||
setMenuOpen(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div data-is-dragging={isDragging} className={styles.indicatorWrapper}>
|
||||
<Menu
|
||||
contentOptions={menuContentOptions}
|
||||
items={menuItems}
|
||||
rootOptions={menuRootOptions}
|
||||
>
|
||||
<div className={styles.menuTrigger} />
|
||||
</Menu>
|
||||
<SplitViewMenuIndicator
|
||||
open={menuOpen}
|
||||
onOpenMenu={openMenu}
|
||||
active={active}
|
||||
setPressed={setPressed}
|
||||
{...listeners}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const menuRootOptions = useMemo(
|
||||
() =>
|
||||
({
|
||||
open: menuOpen,
|
||||
onOpenChange: setOpenMenuManually,
|
||||
}) satisfies MenuProps['rootOptions'],
|
||||
[menuOpen, setOpenMenuManually]
|
||||
);
|
||||
const menuContentOptions = useMemo(
|
||||
() =>
|
||||
({
|
||||
align: 'center',
|
||||
}) satisfies MenuProps['contentOptions'],
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-is-dragging={isDragging}
|
||||
className={styles.indicatorWrapper}
|
||||
>
|
||||
<Menu
|
||||
contentOptions={menuContentOptions}
|
||||
items={menuItems}
|
||||
rootOptions={menuRootOptions}
|
||||
>
|
||||
<div className={styles.menuTrigger} />
|
||||
</Menu>
|
||||
<SplitViewDragHandle
|
||||
ref={dragHandleRef}
|
||||
open={menuOpen}
|
||||
onOpenMenu={openMenu}
|
||||
active={isActive}
|
||||
dragging={isDragging}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1,42 +1,44 @@
|
||||
import { MenuItem } from '@affine/component';
|
||||
import {
|
||||
type DropTargetDragEvent,
|
||||
MenuItem,
|
||||
shallowUpdater,
|
||||
useDraggable,
|
||||
useDropTarget,
|
||||
} from '@affine/component';
|
||||
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import {
|
||||
ExpandCloseIcon,
|
||||
MoveToLeftDuotoneIcon,
|
||||
MoveToRightDuotoneIcon,
|
||||
SoloViewIcon,
|
||||
CloseIcon,
|
||||
ExpandFullIcon,
|
||||
InsertLeftIcon,
|
||||
InsertRightIcon,
|
||||
} from '@blocksuite/icons/rc';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { assignInlineVars } from '@vanilla-extract/dynamic';
|
||||
import type {
|
||||
Dispatch,
|
||||
HTMLAttributes,
|
||||
PropsWithChildren,
|
||||
RefObject,
|
||||
SetStateAction,
|
||||
} from 'react';
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useAtom } from 'jotai';
|
||||
import type { HTMLAttributes, PropsWithChildren } from 'react';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
|
||||
import type { View } from '../../entities/view';
|
||||
import { WorkbenchService } from '../../services/workbench';
|
||||
import { SplitViewIndicator } from './indicator';
|
||||
import { ResizeHandle } from './resize-handle';
|
||||
import * as styles from './split-view.css';
|
||||
import {
|
||||
draggingOverViewAtom,
|
||||
draggingViewAtom,
|
||||
resizingViewAtom,
|
||||
} from './state';
|
||||
import { allowedSplitViewEntityTypes } from './types';
|
||||
|
||||
export interface SplitViewPanelProps
|
||||
extends PropsWithChildren<HTMLAttributes<HTMLDivElement>> {
|
||||
view: View;
|
||||
index: number;
|
||||
resizeHandle?: React.ReactNode;
|
||||
setSlots?: Dispatch<
|
||||
SetStateAction<Record<string, RefObject<HTMLDivElement | null>>>
|
||||
>;
|
||||
onMove: (from: number, to: number) => void;
|
||||
onResizing: (dxy: { x: number; y: number }) => void;
|
||||
draggingEntity: boolean;
|
||||
}
|
||||
|
||||
export const SplitViewPanelContainer = ({
|
||||
@@ -50,81 +52,236 @@ export const SplitViewPanelContainer = ({
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate the order of the panel
|
||||
*/
|
||||
function calculateOrder(
|
||||
index: number,
|
||||
draggingIndex: number,
|
||||
droppingIndex: number
|
||||
) {
|
||||
// If not dragging or invalid indices, return original index
|
||||
if (draggingIndex === -1 || draggingIndex < 0 || droppingIndex < 0) {
|
||||
return index;
|
||||
}
|
||||
|
||||
// If this is the dragging item, move it to the dropping position
|
||||
if (index === draggingIndex) {
|
||||
return droppingIndex;
|
||||
}
|
||||
|
||||
// If dropping before the dragging item
|
||||
if (droppingIndex < draggingIndex) {
|
||||
// Items between drop and drag positions shift right
|
||||
if (index >= droppingIndex && index < draggingIndex) {
|
||||
return index + 1;
|
||||
}
|
||||
}
|
||||
// If dropping after the dragging item
|
||||
else if (
|
||||
droppingIndex > draggingIndex &&
|
||||
index > draggingIndex &&
|
||||
index <= droppingIndex
|
||||
) {
|
||||
// Items between drag and drop positions shift left
|
||||
return index - 1;
|
||||
}
|
||||
|
||||
// For all other items, keep their original position
|
||||
return index;
|
||||
}
|
||||
|
||||
export const SplitViewPanel = memo(function SplitViewPanel({
|
||||
children,
|
||||
view,
|
||||
setSlots,
|
||||
onMove,
|
||||
onResizing,
|
||||
draggingEntity,
|
||||
index,
|
||||
}: SplitViewPanelProps) {
|
||||
const [indicatorPressed, setIndicatorPressed] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const size = useLiveData(view.size$);
|
||||
const workbench = useService(WorkbenchService).workbench;
|
||||
|
||||
const activeView = useLiveData(workbench.activeView$);
|
||||
const views = useLiveData(workbench.views$);
|
||||
const isLast = views[views.length - 1] === view;
|
||||
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
transform,
|
||||
transition,
|
||||
isDragging: dndIsDragging,
|
||||
setNodeRef,
|
||||
} = useSortable({ id: view.id, attributes: { role: 'group' } });
|
||||
|
||||
const isDragging = dndIsDragging || indicatorPressed;
|
||||
const isActive = activeView === view;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (ref.current) {
|
||||
setSlots?.(slots => ({ ...slots, [view.id]: ref }));
|
||||
}
|
||||
}, [setSlots, view.id]);
|
||||
const [draggingView, setDraggingView] = useAtom(draggingViewAtom);
|
||||
const [draggingOverView, setDraggingOverView] = useAtom(draggingOverViewAtom);
|
||||
const [resizingView, setResizingView] = useAtom(resizingViewAtom);
|
||||
|
||||
const style = useMemo(
|
||||
() => ({
|
||||
...assignInlineVars({ '--size': size.toString() }),
|
||||
}),
|
||||
[size]
|
||||
);
|
||||
const dragStyle = useMemo(
|
||||
() => ({
|
||||
transform: `translate3d(${transform?.x ?? 0}px, 0, 0)`,
|
||||
transition,
|
||||
}),
|
||||
[transform, transition]
|
||||
const order = useMemo(
|
||||
() =>
|
||||
calculateOrder(
|
||||
index,
|
||||
draggingView?.index ?? -1,
|
||||
draggingOverView?.index ?? -1
|
||||
),
|
||||
[index, draggingView, draggingOverView]
|
||||
);
|
||||
|
||||
const isFirst = order === 0;
|
||||
const isLast = views.length - 1 === order;
|
||||
|
||||
const style = useMemo(() => {
|
||||
return {
|
||||
...assignInlineVars({
|
||||
[styles.size]: size.toString(),
|
||||
[styles.panelOrder]: order.toString(),
|
||||
}),
|
||||
};
|
||||
}, [size, order]);
|
||||
|
||||
const { dropTargetRef } = useDropTarget<AffineDNDData>(() => {
|
||||
const handleDrag = (data: DropTargetDragEvent<AffineDNDData>) => {
|
||||
// only the first view has left edge
|
||||
const edge = data.closestEdge as 'left' | 'right';
|
||||
const switchEdge = edge === 'left' && !isFirst;
|
||||
|
||||
const newDraggingOver = {
|
||||
view: switchEdge ? views[index - 1] : view,
|
||||
index: order,
|
||||
edge: switchEdge ? 'right' : edge,
|
||||
};
|
||||
|
||||
setDraggingOverView(shallowUpdater(newDraggingOver));
|
||||
};
|
||||
|
||||
return {
|
||||
closestEdge: {
|
||||
allowedEdges: ['left', 'right'],
|
||||
},
|
||||
isSticky: true,
|
||||
canDrop(data) {
|
||||
const entityType = data.source.data.entity?.type;
|
||||
return (
|
||||
data.source.data.from?.at === 'workbench:view' ||
|
||||
data.source.data.from?.at === 'workbench:link' ||
|
||||
(!!entityType && allowedSplitViewEntityTypes.has(entityType))
|
||||
);
|
||||
},
|
||||
onDragEnter: handleDrag,
|
||||
onDrag: handleDrag,
|
||||
};
|
||||
}, [index, isFirst, order, setDraggingOverView, view, views]);
|
||||
|
||||
const { dragRef, dragHandleRef } = useDraggable<AffineDNDData>(() => {
|
||||
return {
|
||||
data: () => {
|
||||
return {
|
||||
from: {
|
||||
at: 'workbench:view',
|
||||
viewId: view.id,
|
||||
},
|
||||
};
|
||||
},
|
||||
onDrop() {
|
||||
if (order !== index && draggingOverView) {
|
||||
onMove?.(index, draggingOverView.index);
|
||||
}
|
||||
setDraggingView(null);
|
||||
setDraggingOverView(null);
|
||||
},
|
||||
onDragStart() {
|
||||
setDraggingView({
|
||||
view,
|
||||
index: order,
|
||||
});
|
||||
},
|
||||
disableDragPreview: true,
|
||||
};
|
||||
}, [
|
||||
draggingOverView,
|
||||
index,
|
||||
onMove,
|
||||
order,
|
||||
setDraggingOverView,
|
||||
setDraggingView,
|
||||
view,
|
||||
]);
|
||||
|
||||
const dragging = draggingView?.view.id === view.id;
|
||||
|
||||
const onResizeStart = useCallback(() => {
|
||||
setResizingView({ view, index });
|
||||
}, [setResizingView, view, index]);
|
||||
|
||||
const onResizeEnd = useCallback(() => {
|
||||
setResizingView(null);
|
||||
}, [setResizingView]);
|
||||
|
||||
const indicatingEdge =
|
||||
draggingOverView?.view === view ? draggingOverView.edge : null;
|
||||
|
||||
return (
|
||||
<SplitViewPanelContainer
|
||||
style={style}
|
||||
data-is-dragging={isDragging}
|
||||
data-is-dragging={dragging}
|
||||
data-is-active={isActive && views.length > 1}
|
||||
data-is-first={isFirst}
|
||||
data-is-last={isLast}
|
||||
data-testid="split-view-panel"
|
||||
draggable={false} // only drag via drag handle
|
||||
>
|
||||
{isFirst ? (
|
||||
<ResizeHandle
|
||||
edge="left"
|
||||
view={view}
|
||||
state={
|
||||
draggingEntity && indicatingEdge === 'left'
|
||||
? 'drop-indicator'
|
||||
: 'idle'
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={dragStyle}
|
||||
ref={node => {
|
||||
dropTargetRef.current = node;
|
||||
dragRef.current = node;
|
||||
}}
|
||||
className={styles.splitViewPanelDrag}
|
||||
{...attributes}
|
||||
>
|
||||
<div className={styles.splitViewPanelContent} ref={ref} />
|
||||
{views.length > 1 ? (
|
||||
<div draggable={false} className={styles.splitViewPanelContent}>
|
||||
{children}
|
||||
</div>
|
||||
{views.length > 1 && onMove ? (
|
||||
<SplitViewIndicator
|
||||
listeners={listeners}
|
||||
isDragging={isDragging}
|
||||
view={view}
|
||||
isActive={isActive}
|
||||
menuItems={<SplitViewMenu view={view} />}
|
||||
setPressed={setIndicatorPressed}
|
||||
isDragging={dragging}
|
||||
dragHandleRef={dragHandleRef}
|
||||
menuItems={<SplitViewMenu view={view} onMove={onMove} />}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{children}
|
||||
{!draggingView ? (
|
||||
<ResizeHandle
|
||||
edge="right"
|
||||
view={view}
|
||||
state={
|
||||
resizingView?.view.id === view.id
|
||||
? 'resizing'
|
||||
: draggingEntity && indicatingEdge === 'right'
|
||||
? 'drop-indicator'
|
||||
: 'idle'
|
||||
}
|
||||
onResizeStart={onResizeStart}
|
||||
onResizeEnd={onResizeEnd}
|
||||
onResizing={onResizing}
|
||||
/>
|
||||
) : null}
|
||||
</SplitViewPanelContainer>
|
||||
);
|
||||
});
|
||||
|
||||
const SplitViewMenu = ({ view }: { view: View }) => {
|
||||
const SplitViewMenu = ({
|
||||
view,
|
||||
onMove,
|
||||
}: {
|
||||
view: View;
|
||||
onMove: (from: number, to: number) => void;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const workbench = useService(WorkbenchService).workbench;
|
||||
const views = useLiveData(workbench.views$);
|
||||
@@ -136,42 +293,39 @@ const SplitViewMenu = ({ view }: { view: View }) => {
|
||||
[view, workbench]
|
||||
);
|
||||
const handleMoveLeft = useCallback(() => {
|
||||
workbench.moveView(viewIndex, viewIndex - 1);
|
||||
}, [viewIndex, workbench]);
|
||||
onMove(viewIndex, viewIndex - 1);
|
||||
}, [onMove, viewIndex]);
|
||||
const handleMoveRight = useCallback(() => {
|
||||
workbench.moveView(viewIndex, viewIndex + 1);
|
||||
}, [viewIndex, workbench]);
|
||||
onMove(viewIndex, viewIndex + 1);
|
||||
}, [onMove, viewIndex]);
|
||||
const handleCloseOthers = useCallback(() => {
|
||||
workbench.closeOthers(view);
|
||||
}, [view, workbench]);
|
||||
|
||||
const CloseItem =
|
||||
views.length > 1 ? (
|
||||
<MenuItem prefixIcon={<ExpandCloseIcon />} onClick={handleClose}>
|
||||
<MenuItem prefixIcon={<CloseIcon />} onClick={handleClose}>
|
||||
{t['com.affine.workbench.split-view-menu.close']()}
|
||||
</MenuItem>
|
||||
) : null;
|
||||
|
||||
const MoveLeftItem =
|
||||
viewIndex > 0 && views.length > 1 ? (
|
||||
<MenuItem onClick={handleMoveLeft} prefixIcon={<MoveToLeftDuotoneIcon />}>
|
||||
<MenuItem onClick={handleMoveLeft} prefixIcon={<InsertRightIcon />}>
|
||||
{t['com.affine.workbench.split-view-menu.move-left']()}
|
||||
</MenuItem>
|
||||
) : null;
|
||||
|
||||
const FullScreenItem =
|
||||
views.length > 1 ? (
|
||||
<MenuItem onClick={handleCloseOthers} prefixIcon={<SoloViewIcon />}>
|
||||
<MenuItem onClick={handleCloseOthers} prefixIcon={<ExpandFullIcon />}>
|
||||
{t['com.affine.workbench.split-view-menu.keep-this-one']()}
|
||||
</MenuItem>
|
||||
) : null;
|
||||
|
||||
const MoveRightItem =
|
||||
viewIndex < views.length - 1 ? (
|
||||
<MenuItem
|
||||
onClick={handleMoveRight}
|
||||
prefixIcon={<MoveToRightDuotoneIcon />}
|
||||
>
|
||||
<MenuItem onClick={handleMoveRight} prefixIcon={<InsertLeftIcon />}>
|
||||
{t['com.affine.workbench.split-view-menu.move-right']()}
|
||||
</MenuItem>
|
||||
) : null;
|
||||
|
||||
@@ -1,40 +1,86 @@
|
||||
import { useDropTarget } from '@affine/component';
|
||||
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import type { View } from '../../entities/view';
|
||||
import { WorkbenchService } from '../../services/workbench';
|
||||
import * as styles from './split-view.css';
|
||||
import { draggingOverResizeHandleAtom } from './state';
|
||||
import { allowedSplitViewEntityTypes } from './types';
|
||||
|
||||
interface ResizeHandleProps extends HTMLAttributes<HTMLDivElement> {
|
||||
resizing: boolean;
|
||||
onResizeStart: () => void;
|
||||
onResizeEnd: () => void;
|
||||
onResizing: (offset: { x: number; y: number }) => void;
|
||||
state: 'resizing' | 'drop-indicator' | 'idle';
|
||||
edge: 'left' | 'right';
|
||||
view: View;
|
||||
onResizeStart?: () => void;
|
||||
onResizeEnd?: () => void;
|
||||
onResizing?: (offset: { x: number; y: number }) => void;
|
||||
}
|
||||
export const ResizeHandle = ({
|
||||
resizing,
|
||||
state,
|
||||
view,
|
||||
edge,
|
||||
onResizing,
|
||||
onResizeStart,
|
||||
onResizeEnd,
|
||||
}: ResizeHandleProps) => {
|
||||
const workbench = useService(WorkbenchService).workbench;
|
||||
const views = useLiveData(workbench.views$);
|
||||
|
||||
const draggingOverHandle = useAtomValue(draggingOverResizeHandleAtom);
|
||||
|
||||
const draggingOver =
|
||||
draggingOverHandle?.edge === edge && draggingOverHandle.viewId === view.id;
|
||||
|
||||
const index = views.findIndex(v => v.id === view.id);
|
||||
|
||||
const isLast = index === views.length - 1;
|
||||
const isFirst = index === 0;
|
||||
|
||||
const { dropTargetRef } = useDropTarget<AffineDNDData>(() => {
|
||||
return {
|
||||
data: {
|
||||
at: 'workbench:resize-handle',
|
||||
edge,
|
||||
viewId: view.id,
|
||||
},
|
||||
canDrop: data => {
|
||||
return (
|
||||
(!!data.source.data.entity?.type &&
|
||||
allowedSplitViewEntityTypes.has(data.source.data.entity?.type)) ||
|
||||
data.source.data.from?.at === 'workbench:link'
|
||||
);
|
||||
},
|
||||
};
|
||||
}, [edge, view.id]);
|
||||
|
||||
// TODO(@catsjuice): touch support
|
||||
const onMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!onResizing || !onResizeStart || !onResizeEnd) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
onResizeStart();
|
||||
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 });
|
||||
onResizing?.({ x: dx, y: dy });
|
||||
prevPos.x = e.clientX;
|
||||
prevPos.y = e.clientY;
|
||||
}
|
||||
|
||||
function onMouseUp(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
onResizeEnd();
|
||||
onResizeEnd?.();
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
}
|
||||
|
||||
@@ -44,10 +90,21 @@ export const ResizeHandle = ({
|
||||
[onResizeEnd, onResizeStart, onResizing]
|
||||
);
|
||||
|
||||
const canResize =
|
||||
state === 'idle' &&
|
||||
!(isLast && edge === 'right') &&
|
||||
!(isFirst && edge === 'left');
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dropTargetRef}
|
||||
data-edge={edge}
|
||||
onMouseDown={onMouseDown}
|
||||
data-resizing={resizing || null}
|
||||
data-is-last={isLast}
|
||||
data-is-first={isFirst}
|
||||
data-state={state}
|
||||
data-dragging-over={state === 'drop-indicator' ? draggingOver : false}
|
||||
data-can-resize={canResize}
|
||||
className={styles.resizeHandle}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,55 +1,55 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { createVar, style } from '@vanilla-extract/css';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { createVar, fallbackVar, keyframes, style } from '@vanilla-extract/css';
|
||||
|
||||
const gap = createVar();
|
||||
const borderRadius = createVar();
|
||||
const resizeHandleWidth = createVar();
|
||||
export const size = createVar();
|
||||
export const panelOrder = createVar();
|
||||
const dropIndicatorWidth = createVar();
|
||||
const dropIndicatorOpacity = createVar();
|
||||
const dropIndicatorRadius = createVar();
|
||||
|
||||
export const splitViewRoot = style({
|
||||
vars: {
|
||||
[gap]: '0px',
|
||||
[borderRadius]: '0px',
|
||||
},
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
position: 'relative',
|
||||
borderRadius,
|
||||
gap,
|
||||
|
||||
selectors: {
|
||||
'&[data-client-border="true"]': {
|
||||
vars: {
|
||||
[gap]: '8px',
|
||||
[borderRadius]: '6px',
|
||||
},
|
||||
const expandDropIndicator = keyframes({
|
||||
from: {
|
||||
vars: {
|
||||
[resizeHandleWidth]: '30px',
|
||||
[dropIndicatorWidth]: '3px',
|
||||
[dropIndicatorOpacity]: '1',
|
||||
[dropIndicatorRadius]: '10px',
|
||||
},
|
||||
'&[data-orientation="vertical"]': {
|
||||
flexDirection: 'column',
|
||||
},
|
||||
to: {
|
||||
vars: {
|
||||
[resizeHandleWidth]: '300px',
|
||||
[dropIndicatorWidth]: '100%',
|
||||
[dropIndicatorOpacity]: '0.15',
|
||||
[dropIndicatorRadius]: '4px',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const splitViewPanel = style({
|
||||
flexShrink: 0,
|
||||
flexGrow: 'var(--size, 1)',
|
||||
flexGrow: fallbackVar(size, '1'),
|
||||
position: 'relative',
|
||||
borderRadius: 'inherit',
|
||||
order: panelOrder,
|
||||
display: 'flex',
|
||||
|
||||
selectors: {
|
||||
'[data-orientation="vertical"] &': {
|
||||
height: 0,
|
||||
},
|
||||
'[data-orientation="horizontal"] &': {
|
||||
width: 0,
|
||||
'[data-client-border="false"] &[data-is-first="true"]': {
|
||||
borderTopLeftRadius: borderRadius,
|
||||
},
|
||||
'[data-client-border="false"] &:not([data-is-last="true"]):not([data-is-dragging="true"])':
|
||||
{
|
||||
borderRight: `0.5px solid ${cssVar('borderColor')}`,
|
||||
borderRight: `0.5px solid ${cssVarV2('layer/insideBorder/border')}`,
|
||||
},
|
||||
'&[data-is-dragging="true"]': {
|
||||
zIndex: 1,
|
||||
},
|
||||
'[data-client-border="true"] &': {
|
||||
border: `0.5px solid ${cssVar('borderColor')}`,
|
||||
border: `0.5px solid ${cssVarV2('layer/insideBorder/border')}`,
|
||||
borderTopLeftRadius: borderRadius,
|
||||
borderBottomLeftRadius: borderRadius,
|
||||
borderTopRightRadius: borderRadius,
|
||||
borderBottomRightRadius: borderRadius,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -58,6 +58,7 @@ export const splitViewPanelDrag = style({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: 'inherit',
|
||||
transition: 'opacity 0.2s',
|
||||
|
||||
selectors: {
|
||||
'&::after': {
|
||||
@@ -73,8 +74,12 @@ export const splitViewPanelDrag = style({
|
||||
transition: 'box-shadow 0.5s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
},
|
||||
|
||||
'[data-is-dragging="true"] &::after': {
|
||||
boxShadow: `inset 0 0 0 2px ${cssVar('brandColor')}`,
|
||||
'[data-is-active="true"] &::after': {
|
||||
boxShadow: `inset 0 0 0 1px ${cssVarV2('button/primary')}`,
|
||||
},
|
||||
|
||||
'[data-is-dragging="true"] &': {
|
||||
opacity: 0.5,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -90,49 +95,123 @@ export const resizeHandle = style({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
right: -5,
|
||||
width: 10,
|
||||
width: resizeHandleWidth,
|
||||
// to make sure it's above all-pages's header
|
||||
zIndex: 3,
|
||||
zIndex: 5,
|
||||
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'stretch',
|
||||
cursor: 'col-resize',
|
||||
|
||||
selectors: {
|
||||
'[data-client-border="true"] &': {
|
||||
right: `calc(-5px - ${gap} / 2)`,
|
||||
'&[data-can-resize="false"]:not([data-state="drop-indicator"])': {
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
|
||||
'&[data-state="drop-indicator"]': {
|
||||
vars: {
|
||||
[resizeHandleWidth]: '20px',
|
||||
},
|
||||
},
|
||||
'&[data-edge="left"]': {
|
||||
left: `calc(${resizeHandleWidth} * -0.5)`,
|
||||
right: 'auto',
|
||||
},
|
||||
'&[data-edge="right"]': {
|
||||
left: 'auto',
|
||||
right: `calc(${resizeHandleWidth} * -0.5)`,
|
||||
},
|
||||
'&[data-edge="right"][data-is-last="true"]': {
|
||||
right: 0,
|
||||
left: 'auto',
|
||||
},
|
||||
'[data-client-border="false"] &[data-is-last="true"][data-edge="right"]::before, [data-client-border="false"] &[data-is-last="true"][data-edge="right"]::after':
|
||||
{
|
||||
transform: `translateX(calc(0.5 * ${resizeHandleWidth} - 1px))`,
|
||||
},
|
||||
|
||||
'&[data-can-resize="true"]': {
|
||||
cursor: 'col-resize',
|
||||
},
|
||||
'[data-client-border="true"] &[data-edge="right"]': {
|
||||
right: `calc(${resizeHandleWidth} * -0.5 - 0.5px - ${gap} / 2)`,
|
||||
},
|
||||
[`.${splitViewPanel}[data-is-dragging="true"] &`]: {
|
||||
display: 'none',
|
||||
},
|
||||
|
||||
// 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-state="drop-indicator"][data-dragging-over="true"]': {
|
||||
animationName: expandDropIndicator,
|
||||
animationDuration: '0.5s',
|
||||
animationDelay: '1s',
|
||||
animationFillMode: 'forwards',
|
||||
vars: {
|
||||
[dropIndicatorOpacity]: '1',
|
||||
[dropIndicatorWidth]: '3px',
|
||||
},
|
||||
'[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')}`,
|
||||
|
||||
'&::before, &::after': {
|
||||
content: '""',
|
||||
width: dropIndicatorWidth,
|
||||
position: 'absolute',
|
||||
height: '100%',
|
||||
transition: 'all 0.2s, transform 0s',
|
||||
borderRadius: dropIndicatorRadius,
|
||||
},
|
||||
'&::before': {
|
||||
background: cssVarV2('button/primary'),
|
||||
opacity: dropIndicatorOpacity,
|
||||
},
|
||||
'&[data-state="resizing"]::before, &[data-state="resizing"]::after': {
|
||||
vars: {
|
||||
[dropIndicatorWidth]: '3px',
|
||||
[dropIndicatorOpacity]: '1',
|
||||
},
|
||||
},
|
||||
'&[data-state="drop-indicator"][data-dragging-over="false"]::before': {
|
||||
vars: {
|
||||
[dropIndicatorOpacity]: '0.5',
|
||||
},
|
||||
},
|
||||
'&:is(:hover[data-can-resize="true"], [data-state="resizing"])::before': {
|
||||
vars: {
|
||||
[dropIndicatorWidth]: '3px',
|
||||
[dropIndicatorOpacity]: '1',
|
||||
},
|
||||
},
|
||||
'&:is(:hover[data-can-resize="true"], [data-state="resizing"])::after': {
|
||||
boxShadow: `0px 12px 21px 4px ${cssVarV2('button/primary')}`,
|
||||
opacity: 0.15,
|
||||
},
|
||||
|
||||
// vertical
|
||||
// TODO
|
||||
},
|
||||
});
|
||||
|
||||
export const splitViewRoot = style({
|
||||
vars: {
|
||||
[gap]: '0px',
|
||||
[borderRadius]: '6px',
|
||||
[resizeHandleWidth]: '10px',
|
||||
[dropIndicatorWidth]: '2px',
|
||||
[dropIndicatorOpacity]: '0',
|
||||
[dropIndicatorRadius]: '10px',
|
||||
},
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
position: 'relative',
|
||||
borderRadius,
|
||||
gap,
|
||||
padding: '0 10px',
|
||||
margin: '0 -10px',
|
||||
|
||||
selectors: {
|
||||
'&[data-client-border="true"]': {
|
||||
vars: {
|
||||
[gap]: '8px',
|
||||
},
|
||||
},
|
||||
[`&:has(${resizeHandle}[data-dragging-over="true"])`]: {
|
||||
overflow: 'clip',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,27 +1,18 @@
|
||||
import { useDndMonitor } from '@affine/component';
|
||||
import { useAppSettingHelper } from '@affine/core/components/hooks/affine/use-app-setting-helper';
|
||||
import type { DragEndEvent } from '@dnd-kit/core';
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
horizontalListSortingStrategy,
|
||||
SortableContext,
|
||||
} from '@dnd-kit/sortable';
|
||||
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||
import { useService } from '@toeverything/infra';
|
||||
import clsx from 'clsx';
|
||||
import type { HTMLAttributes, RefObject } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { View } from '../../entities/view';
|
||||
import { WorkbenchService } from '../../services/workbench';
|
||||
import { SplitViewPanel } from './panel';
|
||||
import { ResizeHandle } from './resize-handle';
|
||||
import * as styles from './split-view.css';
|
||||
import { draggingOverResizeHandleAtom } from './state';
|
||||
import { allowedSplitViewEntityTypes, inferToFromEntity } from './types';
|
||||
|
||||
export interface SplitViewProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
@@ -30,12 +21,10 @@ export interface SplitViewProps extends HTMLAttributes<HTMLDivElement> {
|
||||
*/
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
views: View[];
|
||||
renderer: (item: View, index: number) => React.ReactNode;
|
||||
renderer: (item: View) => React.ReactNode;
|
||||
onMove?: (from: number, to: number) => void;
|
||||
}
|
||||
|
||||
type SlotsMap = Record<View['id'], RefObject<HTMLDivElement | null>>;
|
||||
// TODO(@catsjuice): vertical orientation support
|
||||
export const SplitView = ({
|
||||
orientation = 'horizontal',
|
||||
className,
|
||||
@@ -45,29 +34,27 @@ export const SplitView = ({
|
||||
...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(WorkbenchService).workbench;
|
||||
|
||||
// blocksuite's lit host element has an issue on remounting.
|
||||
// Add a workaround here to force remounting after dropping.
|
||||
const [visible, setVisibility] = useState(true);
|
||||
// workaround: blocksuite's lit host element has an issue on remounting.
|
||||
// we do not want the view to change its render ordering here after reordering
|
||||
// instead we use a local state to store the views + its order to avoid remounting
|
||||
const [localViewsState, setLocalViewsState] = useState<View[]>(views);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(
|
||||
PointerSensor,
|
||||
useMemo(
|
||||
/* avoid re-rendering */
|
||||
() => ({
|
||||
activationConstraint: {
|
||||
distance: 0,
|
||||
},
|
||||
}),
|
||||
[]
|
||||
)
|
||||
)
|
||||
);
|
||||
useLayoutEffect(() => {
|
||||
setLocalViewsState(oldViews => {
|
||||
let newViews = oldViews.filter(v => views.includes(v));
|
||||
|
||||
for (const view of views) {
|
||||
if (!newViews.includes(view)) {
|
||||
newViews.push(view);
|
||||
}
|
||||
}
|
||||
|
||||
return newViews;
|
||||
});
|
||||
}, [views]);
|
||||
|
||||
const onResizing = useCallback(
|
||||
(index: number, { x, y }: { x: number; y: number }) => {
|
||||
@@ -85,45 +72,101 @@ export const SplitView = ({
|
||||
[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);
|
||||
setVisibility(false);
|
||||
}
|
||||
const handleOnMove = useCallback(
|
||||
(from: number, to: number) => {
|
||||
onMove?.(from, to);
|
||||
},
|
||||
[onMove, views]
|
||||
[onMove]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
setVisibility(true);
|
||||
}, 0);
|
||||
const [draggingEntity, setDraggingEntity] = useState(false);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}
|
||||
return;
|
||||
}, [visible]);
|
||||
const setDraggingOverResizeHandle = useSetAtom(draggingOverResizeHandleAtom);
|
||||
|
||||
useDndMonitor<AffineDNDData>(() => {
|
||||
return {
|
||||
// todo(@pengx17): external data for monitor is not supported yet
|
||||
// allowExternal: true,
|
||||
canMonitor(data) {
|
||||
// allow dropping doc && tab view to split view panel
|
||||
const from = data.source.data.from;
|
||||
const entity = data.source.data.entity;
|
||||
if (from?.at === 'app-header:tabs') {
|
||||
return false;
|
||||
} else if (
|
||||
entity?.type &&
|
||||
allowedSplitViewEntityTypes.has(entity?.type)
|
||||
) {
|
||||
return true;
|
||||
} else if (from?.at === 'workbench:link') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
onDragStart() {
|
||||
setDraggingEntity(true);
|
||||
},
|
||||
onDrop(data) {
|
||||
setDraggingEntity(false);
|
||||
const candidate = data.location.current.dropTargets.find(
|
||||
target => target.data.at === 'workbench:resize-handle'
|
||||
);
|
||||
|
||||
if (!candidate) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dropTarget = candidate.data as AffineDNDData['draggable']['from'];
|
||||
const entity = data.source.data.entity;
|
||||
const from = data.source.data.from;
|
||||
|
||||
if (dropTarget?.at === 'workbench:resize-handle') {
|
||||
const { edge, viewId } = dropTarget;
|
||||
const index = views.findIndex(v => v.id === viewId);
|
||||
const at = (() => {
|
||||
if (edge === 'left') {
|
||||
if (index === 0) {
|
||||
return 'head';
|
||||
}
|
||||
return index - 1;
|
||||
} else if (edge === 'right') {
|
||||
if (index === views.length - 1) {
|
||||
return 'tail';
|
||||
}
|
||||
return index + 1;
|
||||
} else {
|
||||
return 'tail';
|
||||
}
|
||||
})();
|
||||
|
||||
const to = entity
|
||||
? inferToFromEntity(entity)
|
||||
: from?.at === 'workbench:link'
|
||||
? from.to
|
||||
: null;
|
||||
|
||||
if (to) {
|
||||
workbench.createView(at, to);
|
||||
}
|
||||
}
|
||||
},
|
||||
onDropTargetChange(data) {
|
||||
const candidate = data.location.current.dropTargets.find(
|
||||
target => target.data.at === 'workbench:resize-handle'
|
||||
);
|
||||
|
||||
if (!candidate) {
|
||||
setDraggingOverResizeHandle(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setDraggingOverResizeHandle({
|
||||
viewId: candidate.data.viewId as string,
|
||||
edge: candidate.data.edge as 'left' | 'right',
|
||||
});
|
||||
},
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -133,29 +176,19 @@ export const SplitView = ({
|
||||
data-client-border={appSettings.clientBorder}
|
||||
{...attrs}
|
||||
>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={views} strategy={horizontalListSortingStrategy}>
|
||||
{views.map((view, index) =>
|
||||
visible ? (
|
||||
<SplitViewPanel view={view} key={view.id} setSlots={setSlots}>
|
||||
{resizeHandleRenderer(view, index)}
|
||||
</SplitViewPanel>
|
||||
) : null
|
||||
)}
|
||||
</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}`
|
||||
{localViewsState.map(view => {
|
||||
const order = views.indexOf(view);
|
||||
return (
|
||||
<SplitViewPanel
|
||||
view={view}
|
||||
index={order}
|
||||
key={view.id}
|
||||
onMove={handleOnMove}
|
||||
onResizing={dxy => onResizing(order, dxy)}
|
||||
draggingEntity={draggingEntity}
|
||||
>
|
||||
{renderer(view)}
|
||||
</SplitViewPanel>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { atom } from 'jotai';
|
||||
|
||||
import type { View } from '../../entities/view';
|
||||
|
||||
// global split view ui state
|
||||
export const draggingOverViewAtom = atom<{
|
||||
view: View;
|
||||
index: number;
|
||||
edge: 'left' | 'right';
|
||||
} | null>(null);
|
||||
|
||||
export const draggingViewAtom = atom<{
|
||||
view: View;
|
||||
index: number;
|
||||
} | null>(null);
|
||||
|
||||
export const resizingViewAtom = atom<{
|
||||
view: View;
|
||||
index: number;
|
||||
} | null>(null);
|
||||
|
||||
export const draggingOverResizeHandleAtom = atom<{
|
||||
viewId: string;
|
||||
edge: 'left' | 'right';
|
||||
} | null>(null);
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { AffineDNDEntity } from '@affine/core/types/dnd';
|
||||
|
||||
export const allowedSplitViewEntityTypes: Set<AffineDNDEntity['type']> =
|
||||
new Set(['doc', 'collection', 'tag']);
|
||||
|
||||
export const inferToFromEntity = (entity: AffineDNDEntity) => {
|
||||
if (entity.type === 'doc') {
|
||||
return `/${entity.id}`;
|
||||
} else if (entity.type === 'collection') {
|
||||
return `/collection/${entity.id}`;
|
||||
} else if (entity.type === 'tag') {
|
||||
return `/tag/${entity.id}`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useDraggable } from '@affine/component';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import type { AffineDNDData, AffineDNDEntity } from '@affine/core/types/dnd';
|
||||
import { isNewTabTrigger } from '@affine/core/utils';
|
||||
import { useLiveData, useServices } from '@toeverything/infra';
|
||||
import { type To } from 'history';
|
||||
import { forwardRef, type MouseEvent } from 'react';
|
||||
|
||||
import { FeatureFlagService } from '../../feature-flag';
|
||||
import { resolveRouteLinkMeta } from '../../navigation/utils';
|
||||
import { WorkbenchService } from '../services/workbench';
|
||||
|
||||
export type WorkbenchLinkProps = React.PropsWithChildren<
|
||||
@@ -15,20 +17,45 @@ export type WorkbenchLinkProps = React.PropsWithChildren<
|
||||
} & React.HTMLProps<HTMLAnchorElement>
|
||||
>;
|
||||
|
||||
function resolveToEntity(
|
||||
to: To,
|
||||
basename: string
|
||||
): AffineDNDEntity | undefined {
|
||||
const link =
|
||||
basename +
|
||||
(typeof to === 'string' ? to : `${to.pathname}${to.search}${to.hash}`);
|
||||
const info = resolveRouteLinkMeta(link);
|
||||
|
||||
if (info?.moduleName === 'doc') {
|
||||
return {
|
||||
type: 'doc',
|
||||
id: info.docId,
|
||||
};
|
||||
} else if (info?.moduleName === 'collection') {
|
||||
return {
|
||||
type: 'collection',
|
||||
id: info.subModuleName,
|
||||
};
|
||||
} else if (info?.moduleName === 'tag') {
|
||||
return {
|
||||
type: 'tag',
|
||||
id: info.subModuleName,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export const WorkbenchLink = forwardRef<HTMLAnchorElement, WorkbenchLinkProps>(
|
||||
function WorkbenchLink({ to, onClick, replaceHistory, ...other }, ref) {
|
||||
const { featureFlagService, workbenchService } = useServices({
|
||||
FeatureFlagService,
|
||||
const { workbenchService } = useServices({
|
||||
WorkbenchService,
|
||||
});
|
||||
const enableMultiView = useLiveData(
|
||||
featureFlagService.flags.enable_multi_view.$
|
||||
);
|
||||
const workbench = workbenchService.workbench;
|
||||
const basename = useLiveData(workbench.basename$);
|
||||
const link =
|
||||
basename +
|
||||
(typeof to === 'string' ? to : `${to.pathname}${to.search}${to.hash}`);
|
||||
const stringTo =
|
||||
typeof to === 'string' ? to : `${to.pathname}${to.search}${to.hash}`;
|
||||
const link = basename + stringTo;
|
||||
const handleClick = useAsyncCallback(
|
||||
async (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
onClick?.(event);
|
||||
@@ -37,9 +64,7 @@ export const WorkbenchLink = forwardRef<HTMLAnchorElement, WorkbenchLinkProps>(
|
||||
}
|
||||
const at = (() => {
|
||||
if (isNewTabTrigger(event)) {
|
||||
return BUILD_CONFIG.isElectron && event.altKey && enableMultiView
|
||||
? 'tail'
|
||||
: 'new-tab';
|
||||
return BUILD_CONFIG.isElectron && event.altKey ? 'tail' : 'new-tab';
|
||||
}
|
||||
return 'active';
|
||||
})();
|
||||
@@ -47,15 +72,32 @@ export const WorkbenchLink = forwardRef<HTMLAnchorElement, WorkbenchLinkProps>(
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
},
|
||||
[enableMultiView, onClick, replaceHistory, to, workbench]
|
||||
[onClick, replaceHistory, to, workbench]
|
||||
);
|
||||
|
||||
// eslint suspicious runtime error
|
||||
// eslint-disable-next-line react/no-danger-with-children
|
||||
const { dragRef } = useDraggable<AffineDNDData>(() => {
|
||||
return {
|
||||
data: {
|
||||
entity: resolveToEntity(to, basename),
|
||||
from: {
|
||||
at: 'workbench:link',
|
||||
to: stringTo,
|
||||
},
|
||||
},
|
||||
};
|
||||
}, [to, basename, stringTo]);
|
||||
|
||||
return (
|
||||
<a
|
||||
{...other}
|
||||
ref={ref}
|
||||
ref={node => {
|
||||
dragRef.current = node;
|
||||
if (typeof ref === 'function') {
|
||||
ref(node);
|
||||
} else if (ref) {
|
||||
ref.current = node;
|
||||
}
|
||||
}}
|
||||
href={link}
|
||||
onClick={handleClick}
|
||||
onAuxClick={handleClick}
|
||||
|
||||
@@ -5,7 +5,6 @@ export const workbenchRootContainer = style({
|
||||
display: 'flex',
|
||||
height: '100%',
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
export const workbenchViewContainer = style({
|
||||
|
||||
@@ -48,8 +48,8 @@ export const WorkbenchRoot = memo(() => {
|
||||
|
||||
useAdapter(workbench, basename);
|
||||
|
||||
const panelRenderer = useCallback((view: View, index: number) => {
|
||||
return <WorkbenchView key={view.id} view={view} index={index} />;
|
||||
const panelRenderer = useCallback((view: View) => {
|
||||
return <WorkbenchView view={view} />;
|
||||
}, []);
|
||||
|
||||
const onMove = useCallback(
|
||||
@@ -78,12 +78,12 @@ export const WorkbenchRoot = memo(() => {
|
||||
|
||||
WorkbenchRoot.displayName = 'memo(WorkbenchRoot)';
|
||||
|
||||
const WorkbenchView = ({ view, index }: { view: View; index: number }) => {
|
||||
const WorkbenchView = ({ view }: { view: View }) => {
|
||||
const workbench = useService(WorkbenchService).workbench;
|
||||
|
||||
const handleOnFocus = useCallback(() => {
|
||||
workbench.active(index);
|
||||
}, [workbench, index]);
|
||||
workbench.active(view);
|
||||
}, [workbench, view]);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
@@ -149,7 +149,7 @@ const WorkbenchSidebar = () => {
|
||||
<ResizePanel
|
||||
floating={floating}
|
||||
resizeHandlePos="left"
|
||||
resizeHandleOffset={0}
|
||||
resizeHandleOffset={clientBorder && sidebarOpen ? 3 : 0}
|
||||
width={width}
|
||||
resizing={resizing}
|
||||
onResizing={setResizing}
|
||||
|
||||
@@ -1,28 +1,30 @@
|
||||
import type { DNDData } from '@affine/component';
|
||||
|
||||
export type AffineDNDEntity =
|
||||
| {
|
||||
type: 'doc';
|
||||
id: string;
|
||||
}
|
||||
| {
|
||||
type: 'folder';
|
||||
id: string;
|
||||
}
|
||||
| {
|
||||
type: 'collection';
|
||||
id: string;
|
||||
}
|
||||
| {
|
||||
type: 'tag';
|
||||
id: string;
|
||||
}
|
||||
| {
|
||||
type: 'custom-property';
|
||||
id: string;
|
||||
};
|
||||
|
||||
export interface AffineDNDData extends DNDData {
|
||||
draggable: {
|
||||
entity?:
|
||||
| {
|
||||
type: 'doc';
|
||||
id: string;
|
||||
}
|
||||
| {
|
||||
type: 'folder';
|
||||
id: string;
|
||||
}
|
||||
| {
|
||||
type: 'collection';
|
||||
id: string;
|
||||
}
|
||||
| {
|
||||
type: 'tag';
|
||||
id: string;
|
||||
}
|
||||
| {
|
||||
type: 'custom-property';
|
||||
id: string;
|
||||
};
|
||||
entity?: AffineDNDEntity;
|
||||
from?:
|
||||
| {
|
||||
at: 'explorer:organize:folder-node';
|
||||
@@ -79,6 +81,19 @@ export interface AffineDNDData extends DNDData {
|
||||
at: 'doc-detail:header';
|
||||
docId: string;
|
||||
}
|
||||
| {
|
||||
at: 'workbench:view';
|
||||
viewId: string;
|
||||
}
|
||||
| {
|
||||
at: 'workbench:link';
|
||||
to: string;
|
||||
}
|
||||
| {
|
||||
at: 'workbench:resize-handle';
|
||||
viewId: string;
|
||||
edge: 'left' | 'right';
|
||||
}
|
||||
| {
|
||||
at: 'blocksuite-editor';
|
||||
}
|
||||
@@ -114,5 +129,9 @@ export interface AffineDNDData extends DNDData {
|
||||
| {
|
||||
at: 'app-header:tabs';
|
||||
}
|
||||
| {
|
||||
at: 'workbench:view';
|
||||
viewId: string;
|
||||
}
|
||||
| Record<string, unknown>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user