mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-12 23:56:36 +08: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);
|
const menu = Menu.buildFromTemplate(template);
|
||||||
menu.popup();
|
menu.popup();
|
||||||
|
|
||||||
let unsub: (() => void) | undefined;
|
let unsub: (() => void) | undefined;
|
||||||
const subscription = WebContentViewsManager.instance.tabAction$.subscribe(
|
const subscription = WebContentViewsManager.instance.tabAction$.subscribe(
|
||||||
action => {
|
action => {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { assignInlineVars } from '@vanilla-extract/dynamic';
|
import { assignInlineVars } from '@vanilla-extract/dynamic';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { forwardRef, useCallback, useLayoutEffect, useRef } from 'react';
|
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 { Tooltip, type TooltipProps } from '../../ui/tooltip';
|
||||||
import * as styles from './resize-panel.css';
|
import * as styles from './resize-panel.css';
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ export interface ResizeHandleProps
|
|||||||
tooltipShortcut?: TooltipProps['shortcut'];
|
tooltipShortcut?: TooltipProps['shortcut'];
|
||||||
tooltipOptions?: Partial<Omit<TooltipProps, 'content' | 'shortcut'>>;
|
tooltipOptions?: Partial<Omit<TooltipProps, 'content' | 'shortcut'>>;
|
||||||
tooltipShortcutClassName?: string;
|
tooltipShortcutClassName?: string;
|
||||||
|
dropTargetOptions?: Parameters<typeof useDropTarget>[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResizePanelProps
|
export interface ResizePanelProps
|
||||||
@@ -40,6 +42,7 @@ export interface ResizePanelProps
|
|||||||
resizeHandleTooltipOptions?: Partial<
|
resizeHandleTooltipOptions?: Partial<
|
||||||
Omit<TooltipProps, 'content' | 'shortcut'>
|
Omit<TooltipProps, 'content' | 'shortcut'>
|
||||||
>;
|
>;
|
||||||
|
resizeHandleDropTargetOptions?: Parameters<typeof useDropTarget>[0];
|
||||||
enableAnimation?: boolean;
|
enableAnimation?: boolean;
|
||||||
width: number;
|
width: number;
|
||||||
unmountOnExit?: boolean;
|
unmountOnExit?: boolean;
|
||||||
@@ -56,6 +59,7 @@ const ResizeHandle = ({
|
|||||||
resizeHandlePos,
|
resizeHandlePos,
|
||||||
resizeHandleOffset,
|
resizeHandleOffset,
|
||||||
resizeHandleVerticalPadding,
|
resizeHandleVerticalPadding,
|
||||||
|
dropTargetOptions,
|
||||||
open,
|
open,
|
||||||
onOpen,
|
onOpen,
|
||||||
onResizing,
|
onResizing,
|
||||||
@@ -115,6 +119,10 @@ const ResizeHandle = ({
|
|||||||
[maxWidth, resizeHandlePos, minWidth, onWidthChange, onResizing, onOpen]
|
[maxWidth, resizeHandlePos, minWidth, onWidthChange, onResizing, onOpen]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { dropTargetRef } = useDropTarget(dropTargetOptions, [
|
||||||
|
dropTargetOptions,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={tooltip}
|
content={tooltip}
|
||||||
@@ -125,7 +133,10 @@ const ResizeHandle = ({
|
|||||||
<div
|
<div
|
||||||
{...rest}
|
{...rest}
|
||||||
data-testid="resize-handle"
|
data-testid="resize-handle"
|
||||||
ref={ref}
|
ref={node => {
|
||||||
|
ref.current = node;
|
||||||
|
dropTargetRef.current = node;
|
||||||
|
}}
|
||||||
style={assignInlineVars({
|
style={assignInlineVars({
|
||||||
[styles.resizeHandleOffsetVar]: `${resizeHandleOffset ?? 0}px`,
|
[styles.resizeHandleOffsetVar]: `${resizeHandleOffset ?? 0}px`,
|
||||||
[styles.resizeHandleVerticalPadding]: `${
|
[styles.resizeHandleVerticalPadding]: `${
|
||||||
@@ -169,17 +180,18 @@ export const ResizePanel = forwardRef<HTMLDivElement, ResizePanelProps>(
|
|||||||
resizeHandleTooltipShortcut,
|
resizeHandleTooltipShortcut,
|
||||||
resizeHandleTooltipShortcutClassName,
|
resizeHandleTooltipShortcutClassName,
|
||||||
resizeHandleTooltipOptions,
|
resizeHandleTooltipOptions,
|
||||||
|
resizeHandleDropTargetOptions,
|
||||||
...rest
|
...rest
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) {
|
) {
|
||||||
const safeWidth = Math.min(maxWidth, Math.max(minWidth, width));
|
const safeWidth = Math.min(maxWidth, Math.max(minWidth, width));
|
||||||
const [{ status }, toggle] = useTransition({
|
const [{ status }, toggle] = useTransitionState({
|
||||||
timeout: animationTimeout,
|
timeout: animationTimeout,
|
||||||
});
|
});
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
toggle(open);
|
toggle(open);
|
||||||
}, [open]);
|
}, [open, toggle]);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
{...rest}
|
{...rest}
|
||||||
@@ -213,6 +225,7 @@ export const ResizePanel = forwardRef<HTMLDivElement, ResizePanelProps>(
|
|||||||
onWidthChange={onWidthChange}
|
onWidthChange={onWidthChange}
|
||||||
open={open}
|
open={open}
|
||||||
resizing={resizing}
|
resizing={resizing}
|
||||||
|
dropTargetOptions={resizeHandleDropTargetOptions}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
const context = useContext(DNDContext);
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
const options = useMemo(() => {
|
const options = useMemo(() => {
|
||||||
const opts = getOptions();
|
const opts = getOptions();
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
} from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item';
|
} from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item';
|
||||||
import { useContext, useEffect, useMemo, useRef, useState } from 'react';
|
import { useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { shallowUpdater } from '../../utils';
|
||||||
|
import { getAdaptedEventArgs, isExternalDrag } from './common';
|
||||||
import { DNDContext } from './context';
|
import { DNDContext } from './context';
|
||||||
import type { DNDData, fromExternalData } from './types';
|
import type { DNDData, fromExternalData } from './types';
|
||||||
|
|
||||||
@@ -44,7 +46,7 @@ export type DropTargetTreeInstruction = Instruction;
|
|||||||
|
|
||||||
export type ExternalDragPayload = ExternalDragType['payload'];
|
export type ExternalDragPayload = ExternalDragType['payload'];
|
||||||
|
|
||||||
type DropTargetGetFeedback<D extends DNDData> = Parameters<
|
export type DropTargetGetFeedback<D extends DNDData> = Parameters<
|
||||||
NonNullable<Parameters<typeof dropTargetForElements>[0]['canDrop']>
|
NonNullable<Parameters<typeof dropTargetForElements>[0]['canDrop']>
|
||||||
>[0] & {
|
>[0] & {
|
||||||
source: {
|
source: {
|
||||||
@@ -59,38 +61,6 @@ type DropTargetGet<T, D extends DNDData> =
|
|||||||
| T
|
| T
|
||||||
| ((data: DropTargetGetFeedback<D>) => 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>(
|
function dropTargetGet<T, D extends DNDData>(
|
||||||
get: T,
|
get: T,
|
||||||
options: DropTargetOptions<D>
|
options: DropTargetOptions<D>
|
||||||
@@ -110,7 +80,7 @@ function dropTargetGet<T, D extends DNDData>(
|
|||||||
) => {
|
) => {
|
||||||
if (typeof get === 'function') {
|
if (typeof get === 'function') {
|
||||||
return (get as any)({
|
return (get as any)({
|
||||||
...getAdaptedEventArgs(options, args),
|
...getAdaptedEventArgs(args, options.fromExternalData),
|
||||||
get treeInstruction() {
|
get treeInstruction() {
|
||||||
return options.treeInstruction
|
return options.treeInstruction
|
||||||
? extractInstruction(
|
? extractInstruction(
|
||||||
@@ -145,8 +115,8 @@ function dropTargetGet<T, D extends DNDData>(
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
return {
|
return {
|
||||||
|
...getAdaptedEventArgs(args, options.fromExternalData),
|
||||||
...get,
|
...get,
|
||||||
...getAdaptedEventArgs(options, args),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}) as any;
|
}) as any;
|
||||||
@@ -168,6 +138,8 @@ export interface DropTargetOptions<D extends DNDData = DNDData> {
|
|||||||
};
|
};
|
||||||
onDrop?: (data: DropTargetDropEvent<D>) => void;
|
onDrop?: (data: DropTargetDropEvent<D>) => void;
|
||||||
onDrag?: (data: DropTargetDragEvent<D>) => void;
|
onDrag?: (data: DropTargetDragEvent<D>) => void;
|
||||||
|
onDragEnter?: (data: DropTargetDragEvent<D>) => void;
|
||||||
|
onDragLeave?: (data: DropTargetDragEvent<D>) => void;
|
||||||
/**
|
/**
|
||||||
* external data adapter.
|
* external data adapter.
|
||||||
* Will use the external data adapter from the context if not provided.
|
* 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 dropTargetOptions = useMemo(() => {
|
||||||
const wrappedCanDrop = dropTargetGet(options.canDrop, options);
|
const wrappedCanDrop = dropTargetGet(options.canDrop, options);
|
||||||
let _element: HTMLElement | null = null;
|
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 {
|
return {
|
||||||
get element() {
|
get element() {
|
||||||
if (!_element) {
|
if (!_element) {
|
||||||
@@ -285,7 +306,7 @@ export const useDropTarget = <D extends DNDData = DNDData>(
|
|||||||
|
|
||||||
// external data is only available in drop event thus
|
// external data is only available in drop event thus
|
||||||
// this is the only case for getAdaptedEventArgs
|
// this is the only case for getAdaptedEventArgs
|
||||||
const args = getAdaptedEventArgs(options, _args, true);
|
const args = getAdaptedEventArgs(_args, options.fromExternalData, true);
|
||||||
if (
|
if (
|
||||||
isExternalDrag(_args) &&
|
isExternalDrag(_args) &&
|
||||||
options.fromExternalData &&
|
options.fromExternalData &&
|
||||||
@@ -309,7 +330,7 @@ export const useDropTarget = <D extends DNDData = DNDData>(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
getData: (args: DropTargetGetFeedback<D>) => {
|
getData: (args: DropTargetGetFeedback<D>) => {
|
||||||
args = getAdaptedEventArgs(options, args);
|
args = getAdaptedEventArgs(args, options.fromExternalData);
|
||||||
const originData = dropTargetGet(options.data ?? {}, options)(args);
|
const originData = dropTargetGet(options.data ?? {}, options)(args);
|
||||||
const { input, element } = args;
|
const { input, element } = args;
|
||||||
const withInstruction = options.treeInstruction
|
const withInstruction = options.treeInstruction
|
||||||
@@ -332,50 +353,29 @@ export const useDropTarget = <D extends DNDData = DNDData>(
|
|||||||
return withClosestEdge;
|
return withClosestEdge;
|
||||||
},
|
},
|
||||||
onDrag: (args: DropTargetDragEvent<D>) => {
|
onDrag: (args: DropTargetDragEvent<D>) => {
|
||||||
args = getAdaptedEventArgs(options, args);
|
updateDragOver(args, options.onDrag);
|
||||||
if (
|
},
|
||||||
args.location.current.dropTargets[0]?.element ===
|
onDragEnter: (args: DropTargetDragEvent<D>) => {
|
||||||
dropTargetRef.current
|
updateDragOver(args, options.onDragEnter);
|
||||||
) {
|
},
|
||||||
if (enableDraggedOverDraggable.current) {
|
onDragLeave: (args: DropTargetDragEvent<D>) => {
|
||||||
setDraggedOverDraggable(args.source);
|
args = getAdaptedEventArgs(args, options.fromExternalData);
|
||||||
}
|
|
||||||
let instruction = null;
|
const withClosestEdge = options.closestEdge
|
||||||
let closestEdge = null;
|
? attachClosestEdge(args.self.data, {
|
||||||
if (options.treeInstruction) {
|
element: args.self.element,
|
||||||
instruction = extractInstruction(args.self.data);
|
input: args.location.current.input,
|
||||||
setTreeInstruction(instruction);
|
allowedEdges: options.closestEdge.allowedEdges,
|
||||||
if (dropTargetRef.current) {
|
})
|
||||||
dropTargetRef.current.dataset['treeInstruction'] =
|
: args.self.data;
|
||||||
instruction?.type;
|
|
||||||
}
|
options.onDragLeave?.({
|
||||||
}
|
...args,
|
||||||
if (options.closestEdge) {
|
self: { ...args.self, data: withClosestEdge },
|
||||||
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>);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onDropTargetChange: (args: DropTargetDropEvent<D>) => {
|
onDropTargetChange: (args: DropTargetDropEvent<D>) => {
|
||||||
args = getAdaptedEventArgs(options, args);
|
args = getAdaptedEventArgs(args, options.fromExternalData);
|
||||||
if (
|
if (
|
||||||
args.location.current.dropTargets[0]?.element ===
|
args.location.current.dropTargets[0]?.element ===
|
||||||
dropTargetRef.current
|
dropTargetRef.current
|
||||||
|
|||||||
@@ -2,4 +2,5 @@ export * from './context';
|
|||||||
export * from './draggable';
|
export * from './draggable';
|
||||||
export * from './drop-indicator';
|
export * from './drop-indicator';
|
||||||
export * from './drop-target';
|
export * from './drop-target';
|
||||||
|
export * from './monitor';
|
||||||
export * from './types';
|
export * from './types';
|
||||||
|
|||||||
@@ -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 { draggable } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||||||
import type { dropTargetForExternal } from '@atlaskit/pragmatic-drag-and-drop/external/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<
|
export interface DNDData<
|
||||||
Draggable extends Record<string, unknown> = Record<string, unknown>,
|
Draggable extends Record<string, unknown> = Record<string, unknown>,
|
||||||
DropTarget 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-intersection';
|
||||||
export * from './observe-resize';
|
export * from './observe-resize';
|
||||||
|
export * from './shallow-equal';
|
||||||
export { startScopedViewTransition } from './view-transition';
|
export { startScopedViewTransition } from './view-transition';
|
||||||
export * from './with-unit';
|
export * from './with-unit';
|
||||||
|
|||||||
@@ -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",
|
"@blocksuite/icons": "2.2.1",
|
||||||
"@capacitor/app": "^6.0.2",
|
"@capacitor/app": "^6.0.2",
|
||||||
"@capacitor/browser": "^6.0.4",
|
"@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",
|
"@dotlottie/player-component": "^2.7.12",
|
||||||
"@emotion/cache": "^11.14.0",
|
"@emotion/cache": "^11.14.0",
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
|
|||||||
+3
-1
@@ -23,7 +23,9 @@ export const BlocksuiteEditorJournalDocTitle = ({ page }: { page: Blocks }) => {
|
|||||||
<div className="doc-title-container" data-testid="journal-title">
|
<div className="doc-title-container" data-testid="journal-title">
|
||||||
<span data-testid="date">{localizedJournalDate}</span>
|
<span data-testid="date">{localizedJournalDate}</span>
|
||||||
{isTodayJournal ? (
|
{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>
|
<span className={styles.titleDayTag}>{day}</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+3
-1
@@ -8,7 +8,9 @@ export const JournalTodayButton = () => {
|
|||||||
const journalHelper = useJournalRouteHelper();
|
const journalHelper = useJournalRouteHelper();
|
||||||
|
|
||||||
const onToday = useCallback(() => {
|
const onToday = useCallback(() => {
|
||||||
journalHelper.openToday();
|
journalHelper.openToday({
|
||||||
|
replaceHistory: true,
|
||||||
|
});
|
||||||
}, [journalHelper]);
|
}, [journalHelper]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
JournalService,
|
JournalService,
|
||||||
type MaybeDate,
|
type MaybeDate,
|
||||||
} from '@affine/core/modules/journal';
|
} from '@affine/core/modules/journal';
|
||||||
|
import type { WorkbenchOpenOptions } from '@affine/core/modules/workbench/entities/workbench';
|
||||||
import { i18nTime } from '@affine/i18n';
|
import { i18nTime } from '@affine/i18n';
|
||||||
import { track } from '@affine/track';
|
import { track } from '@affine/track';
|
||||||
import { useService, useServices } from '@toeverything/infra';
|
import { useService, useServices } from '@toeverything/infra';
|
||||||
@@ -60,9 +61,9 @@ export const useJournalRouteHelper = () => {
|
|||||||
* open journal by date, create one if not exist
|
* open journal by date, create one if not exist
|
||||||
*/
|
*/
|
||||||
const openJournal = useCallback(
|
const openJournal = useCallback(
|
||||||
(maybeDate: MaybeDate, newTab?: boolean) => {
|
(maybeDate: MaybeDate, options?: WorkbenchOpenOptions) => {
|
||||||
const page = getJournalByDate(maybeDate);
|
const page = getJournalByDate(maybeDate);
|
||||||
workbench.openDoc(page.id, { at: newTab ? 'new-tab' : 'active' });
|
workbench.openDoc(page.id, options);
|
||||||
track.$.navigationPanel.journal.navigate({
|
track.$.navigationPanel.journal.navigate({
|
||||||
to: 'journal',
|
to: 'journal',
|
||||||
});
|
});
|
||||||
@@ -75,9 +76,9 @@ export const useJournalRouteHelper = () => {
|
|||||||
* open today's journal
|
* open today's journal
|
||||||
*/
|
*/
|
||||||
const openToday = useCallback(
|
const openToday = useCallback(
|
||||||
(newTab?: boolean) => {
|
(options: WorkbenchOpenOptions) => {
|
||||||
const date = dayjs().format(JOURNAL_DATE_FORMAT);
|
const date = dayjs().format(JOURNAL_DATE_FORMAT);
|
||||||
return openJournal(date, newTab);
|
return openJournal(date, options);
|
||||||
},
|
},
|
||||||
[openJournal]
|
[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 (
|
return (
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
CompatibleFavoriteItemsAdapter,
|
CompatibleFavoriteItemsAdapter,
|
||||||
FavoriteService,
|
FavoriteService,
|
||||||
} from '@affine/core/modules/favorite';
|
} from '@affine/core/modules/favorite';
|
||||||
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
|
||||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||||
import { WorkspaceService } from '@affine/core/modules/workspace';
|
import { WorkspaceService } from '@affine/core/modules/workspace';
|
||||||
import type { Collection, DeleteCollectionInfo } from '@affine/env/filter';
|
import type { Collection, DeleteCollectionInfo } from '@affine/env/filter';
|
||||||
@@ -65,19 +64,15 @@ export const PageOperationCell = ({
|
|||||||
}: PageOperationCellProps) => {
|
}: PageOperationCellProps) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const {
|
const {
|
||||||
featureFlagService,
|
|
||||||
workspaceService,
|
workspaceService,
|
||||||
compatibleFavoriteItemsAdapter: favAdapter,
|
compatibleFavoriteItemsAdapter: favAdapter,
|
||||||
workbenchService,
|
workbenchService,
|
||||||
} = useServices({
|
} = useServices({
|
||||||
FeatureFlagService,
|
|
||||||
WorkspaceService,
|
WorkspaceService,
|
||||||
CompatibleFavoriteItemsAdapter,
|
CompatibleFavoriteItemsAdapter,
|
||||||
WorkbenchService,
|
WorkbenchService,
|
||||||
});
|
});
|
||||||
const enableSplitView = useLiveData(
|
|
||||||
featureFlagService.flags.enable_multi_view.$
|
|
||||||
);
|
|
||||||
const currentWorkspace = workspaceService.workspace;
|
const currentWorkspace = workspaceService.workspace;
|
||||||
const favourite = useLiveData(favAdapter.isFavorite$(page.id, 'doc'));
|
const favourite = useLiveData(favAdapter.isFavorite$(page.id, 'doc'));
|
||||||
const workbench = workbenchService.workbench;
|
const workbench = workbenchService.workbench;
|
||||||
@@ -194,7 +189,7 @@ export const PageOperationCell = ({
|
|||||||
<MenuItem onClick={onOpenInNewTab} prefixIcon={<OpenInNewIcon />}>
|
<MenuItem onClick={onOpenInNewTab} prefixIcon={<OpenInNewIcon />}>
|
||||||
{t['com.affine.workbench.tab.page-menu-open']()}
|
{t['com.affine.workbench.tab.page-menu-open']()}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
{BUILD_CONFIG.isElectron && enableSplitView ? (
|
{BUILD_CONFIG.isElectron ? (
|
||||||
<MenuItem onClick={onOpenInSplitView} prefixIcon={<SplitViewIcon />}>
|
<MenuItem onClick={onOpenInSplitView} prefixIcon={<SplitViewIcon />}>
|
||||||
{t['com.affine.workbench.split-view.page-menu-open']()}
|
{t['com.affine.workbench.split-view.page-menu-open']()}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { shallowEqual } from '@affine/component';
|
||||||
import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
|
import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
|
||||||
import type { Tag } from '@affine/env/filter';
|
import type { Tag } from '@affine/env/filter';
|
||||||
import { useI18n } from '@affine/i18n';
|
import { useI18n } from '@affine/i18n';
|
||||||
@@ -34,7 +35,6 @@ import type {
|
|||||||
TagListItemProps,
|
TagListItemProps,
|
||||||
TagMeta,
|
TagMeta,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { shallowEqual } from './utils';
|
|
||||||
|
|
||||||
export const ItemGroupHeader = memo(function ItemGroupHeader<
|
export const ItemGroupHeader = memo(function ItemGroupHeader<
|
||||||
T extends ListItem,
|
T extends ListItem,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { shallowEqual } from '@affine/component';
|
||||||
import { DEFAULT_SORT_KEY } from '@affine/env/constant';
|
import { DEFAULT_SORT_KEY } from '@affine/env/constant';
|
||||||
import { atom } from 'jotai';
|
import { atom } from 'jotai';
|
||||||
import { selectAtom } from 'jotai/utils';
|
import { selectAtom } from 'jotai/utils';
|
||||||
@@ -10,7 +11,6 @@ import type {
|
|||||||
MetaRecord,
|
MetaRecord,
|
||||||
VirtualizedListProps,
|
VirtualizedListProps,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { shallowEqual } from './utils';
|
|
||||||
|
|
||||||
// for ease of use in the component tree
|
// for ease of use in the component tree
|
||||||
// note: must use selectAtom to access this atom for efficiency
|
// note: must use selectAtom to access this atom for efficiency
|
||||||
|
|||||||
@@ -56,38 +56,3 @@ export const betweenDaysAgo = (
|
|||||||
): boolean => {
|
): boolean => {
|
||||||
return !withinDaysAgo(date, days0) && withinDaysAgo(date, days1);
|
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 { useDeleteCollectionInfo } from '@affine/core/components/hooks/affine/use-delete-collection-info';
|
||||||
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
||||||
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
|
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
|
||||||
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
|
||||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||||
import type { Collection } from '@affine/env/filter';
|
import type { Collection } from '@affine/env/filter';
|
||||||
import { useI18n } from '@affine/i18n';
|
import { useI18n } from '@affine/i18n';
|
||||||
@@ -36,21 +35,16 @@ export const CollectionOperations = ({
|
|||||||
const {
|
const {
|
||||||
collectionService: service,
|
collectionService: service,
|
||||||
workbenchService,
|
workbenchService,
|
||||||
featureFlagService,
|
|
||||||
workspaceDialogService,
|
workspaceDialogService,
|
||||||
} = useServices({
|
} = useServices({
|
||||||
CollectionService,
|
CollectionService,
|
||||||
WorkbenchService,
|
WorkbenchService,
|
||||||
FeatureFlagService,
|
|
||||||
WorkspaceDialogService,
|
WorkspaceDialogService,
|
||||||
});
|
});
|
||||||
const deleteInfo = useDeleteCollectionInfo();
|
const deleteInfo = useDeleteCollectionInfo();
|
||||||
const workbench = workbenchService.workbench;
|
const workbench = workbenchService.workbench;
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const { openPromptModal } = usePromptModal();
|
const { openPromptModal } = usePromptModal();
|
||||||
const enableMultiView = useLiveData(
|
|
||||||
featureFlagService.flags.enable_multi_view.$
|
|
||||||
);
|
|
||||||
|
|
||||||
const showEditName = useCallback(() => {
|
const showEditName = useCallback(() => {
|
||||||
// use openRenameModal if it is in the sidebar collection list
|
// 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'](),
|
name: t['com.affine.workbench.tab.page-menu-open'](),
|
||||||
click: openCollectionNewTab,
|
click: openCollectionNewTab,
|
||||||
},
|
},
|
||||||
...(BUILD_CONFIG.isElectron && enableMultiView
|
...(BUILD_CONFIG.isElectron
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
icon: <SplitViewIcon />,
|
icon: <SplitViewIcon />,
|
||||||
@@ -172,7 +166,6 @@ export const CollectionOperations = ({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
enableMultiView,
|
|
||||||
t,
|
t,
|
||||||
showEditName,
|
showEditName,
|
||||||
showEdit,
|
showEdit,
|
||||||
|
|||||||
@@ -1,32 +1,19 @@
|
|||||||
import { useCatchEventCallback } from '@affine/core/components/hooks/use-catch-event-hook';
|
import { MenuLinkItem } from '@affine/core/modules/app-sidebar/views';
|
||||||
import {
|
|
||||||
useJournalInfoHelper,
|
|
||||||
useJournalRouteHelper,
|
|
||||||
} from '@affine/core/components/hooks/use-journal';
|
|
||||||
import { MenuItem } from '@affine/core/modules/app-sidebar/views';
|
|
||||||
import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
|
import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
|
||||||
|
import { JournalService } from '@affine/core/modules/journal';
|
||||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||||
import { isNewTabTrigger } from '@affine/core/utils';
|
|
||||||
import { useI18n } from '@affine/i18n';
|
import { useI18n } from '@affine/i18n';
|
||||||
import { TodayIcon } from '@blocksuite/icons/rc';
|
import { TodayIcon } from '@blocksuite/icons/rc';
|
||||||
import { useLiveData, useService } from '@toeverything/infra';
|
import { useLiveData, useService } from '@toeverything/infra';
|
||||||
import { type MouseEvent } from 'react';
|
|
||||||
|
|
||||||
export const AppSidebarJournalButton = () => {
|
export const AppSidebarJournalButton = () => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const docDisplayMetaService = useService(DocDisplayMetaService);
|
const docDisplayMetaService = useService(DocDisplayMetaService);
|
||||||
|
const journalService = useService(JournalService);
|
||||||
const workbench = useService(WorkbenchService).workbench;
|
const workbench = useService(WorkbenchService).workbench;
|
||||||
const location = useLiveData(workbench.location$);
|
const location = useLiveData(workbench.location$);
|
||||||
const { openToday } = useJournalRouteHelper();
|
|
||||||
const maybeDocId = location.pathname.split('/')[1];
|
const maybeDocId = location.pathname.split('/')[1];
|
||||||
const { isJournal } = useJournalInfoHelper(maybeDocId);
|
const isJournal = !!useLiveData(journalService.journalDate$(maybeDocId));
|
||||||
|
|
||||||
const handleOpenToday = useCatchEventCallback(
|
|
||||||
(e: MouseEvent) => {
|
|
||||||
openToday(isNewTabTrigger(e));
|
|
||||||
},
|
|
||||||
[openToday]
|
|
||||||
);
|
|
||||||
|
|
||||||
const JournalIcon = useLiveData(
|
const JournalIcon = useLiveData(
|
||||||
docDisplayMetaService.icon$(maybeDocId, {
|
docDisplayMetaService.icon$(maybeDocId, {
|
||||||
@@ -36,14 +23,13 @@ export const AppSidebarJournalButton = () => {
|
|||||||
const Icon = isJournal ? JournalIcon : TodayIcon;
|
const Icon = isJournal ? JournalIcon : TodayIcon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MenuItem
|
<MenuLinkItem
|
||||||
data-testid="slider-bar-journals-button"
|
data-testid="slider-bar-journals-button"
|
||||||
active={isJournal}
|
active={isJournal}
|
||||||
onClick={handleOpenToday}
|
to={'/journals'}
|
||||||
onAuxClick={handleOpenToday}
|
|
||||||
icon={<Icon />}
|
icon={<Icon />}
|
||||||
>
|
>
|
||||||
{t['com.affine.journal.app-sidebar-title']()}
|
{t['com.affine.journal.app-sidebar-title']()}
|
||||||
</MenuItem>
|
</MenuLinkItem>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -78,27 +78,25 @@ export const mainContainerStyle = style({
|
|||||||
width: '100%',
|
width: '100%',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
overflow: 'clip',
|
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
|
|
||||||
selectors: {
|
selectors: {
|
||||||
'&[data-client-border="true"]': {
|
'&[data-client-border="true"]': {
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
margin: '8px',
|
padding: '8px',
|
||||||
overflow: 'clip',
|
|
||||||
'@media': {
|
'@media': {
|
||||||
print: {
|
print: {
|
||||||
overflow: 'visible',
|
overflow: 'visible',
|
||||||
margin: '0px',
|
padding: '0px',
|
||||||
borderRadius: '0px',
|
borderRadius: '0px',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'&[data-client-border="true"][data-side-bar-open="true"]': {
|
'&[data-client-border="true"][data-side-bar-open="true"]': {
|
||||||
marginLeft: 0,
|
paddingLeft: 0,
|
||||||
},
|
},
|
||||||
'&[data-client-border="true"][data-is-desktop="true"]': {
|
'&[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"]':
|
'&[data-client-border="false"][data-is-desktop="true"][data-side-bar-open="true"]':
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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',
|
path: '/:pageId/attachments/:attachmentId',
|
||||||
lazy: () => import('./pages/workspace/attachment/index'),
|
lazy: () => import('./pages/workspace/attachment/index'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/journals',
|
||||||
|
lazy: () => import('./pages/journals'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '*',
|
path: '*',
|
||||||
lazy: () => import('./pages/404'),
|
lazy: () => import('./pages/404'),
|
||||||
|
|||||||
+1
-7
@@ -11,7 +11,6 @@ import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
|
|||||||
import { CollectionService } from '@affine/core/modules/collection';
|
import { CollectionService } from '@affine/core/modules/collection';
|
||||||
import type { NodeOperation } from '@affine/core/modules/explorer';
|
import type { NodeOperation } from '@affine/core/modules/explorer';
|
||||||
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
|
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
|
||||||
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
|
||||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||||
import { WorkspaceService } from '@affine/core/modules/workspace';
|
import { WorkspaceService } from '@affine/core/modules/workspace';
|
||||||
import { useI18n } from '@affine/i18n';
|
import { useI18n } from '@affine/i18n';
|
||||||
@@ -161,10 +160,6 @@ export const useExplorerCollectionNodeOperationsMenu = (
|
|||||||
onOpenEdit: () => void
|
onOpenEdit: () => void
|
||||||
): NodeOperation[] => {
|
): NodeOperation[] => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const { featureFlagService } = useServices({ FeatureFlagService });
|
|
||||||
const enableMultiView = useLiveData(
|
|
||||||
featureFlagService.flags.enable_multi_view.$
|
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
favorite,
|
favorite,
|
||||||
@@ -246,7 +241,7 @@ export const useExplorerCollectionNodeOperationsMenu = (
|
|||||||
</MenuItem>
|
</MenuItem>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
...(BUILD_CONFIG.isElectron && enableMultiView
|
...(BUILD_CONFIG.isElectron
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
index: 99,
|
index: 99,
|
||||||
@@ -279,7 +274,6 @@ export const useExplorerCollectionNodeOperationsMenu = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
enableMultiView,
|
|
||||||
favorite,
|
favorite,
|
||||||
handleAddDocToCollection,
|
handleAddDocToCollection,
|
||||||
handleDeleteCollection,
|
handleDeleteCollection,
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
|
|||||||
import { DocsService } from '@affine/core/modules/doc';
|
import { DocsService } from '@affine/core/modules/doc';
|
||||||
import type { NodeOperation } from '@affine/core/modules/explorer';
|
import type { NodeOperation } from '@affine/core/modules/explorer';
|
||||||
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
|
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
|
||||||
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
|
||||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||||
import { WorkspaceService } from '@affine/core/modules/workspace';
|
import { WorkspaceService } from '@affine/core/modules/workspace';
|
||||||
import { preventDefault } from '@affine/core/utils';
|
import { preventDefault } from '@affine/core/utils';
|
||||||
@@ -171,7 +170,6 @@ export const useExplorerDocNodeOperationsMenu = (
|
|||||||
}
|
}
|
||||||
): NodeOperation[] => {
|
): NodeOperation[] => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const featureFlagService = useService(FeatureFlagService);
|
|
||||||
const {
|
const {
|
||||||
favorite,
|
favorite,
|
||||||
handleAddLinkedPage,
|
handleAddLinkedPage,
|
||||||
@@ -186,9 +184,6 @@ export const useExplorerDocNodeOperationsMenu = (
|
|||||||
const docService = useService(DocsService);
|
const docService = useService(DocsService);
|
||||||
const docRecord = useLiveData(docService.list.doc$(docId));
|
const docRecord = useLiveData(docService.list.doc$(docId));
|
||||||
const title = useLiveData(docRecord?.title$);
|
const title = useLiveData(docRecord?.title$);
|
||||||
const enableMultiView = useLiveData(
|
|
||||||
featureFlagService.flags.enable_multi_view.$
|
|
||||||
);
|
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -258,7 +253,7 @@ export const useExplorerDocNodeOperationsMenu = (
|
|||||||
</MenuItem>
|
</MenuItem>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
...(BUILD_CONFIG.isElectron && enableMultiView
|
...(BUILD_CONFIG.isElectron
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
index: 100,
|
index: 100,
|
||||||
@@ -305,7 +300,6 @@ export const useExplorerDocNodeOperationsMenu = (
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
docId,
|
docId,
|
||||||
enableMultiView,
|
|
||||||
favorite,
|
favorite,
|
||||||
handleAddLinkedPage,
|
handleAddLinkedPage,
|
||||||
handleDuplicate,
|
handleDuplicate,
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
|||||||
import { DocsService } from '@affine/core/modules/doc';
|
import { DocsService } from '@affine/core/modules/doc';
|
||||||
import type { NodeOperation } from '@affine/core/modules/explorer';
|
import type { NodeOperation } from '@affine/core/modules/explorer';
|
||||||
import { FavoriteService } from '@affine/core/modules/favorite';
|
import { FavoriteService } from '@affine/core/modules/favorite';
|
||||||
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
|
||||||
import { GlobalCacheService } from '@affine/core/modules/storage';
|
import { GlobalCacheService } from '@affine/core/modules/storage';
|
||||||
import { TagService } from '@affine/core/modules/tag';
|
import { TagService } from '@affine/core/modules/tag';
|
||||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||||
@@ -24,7 +23,7 @@ import {
|
|||||||
PlusIcon,
|
PlusIcon,
|
||||||
SplitViewIcon,
|
SplitViewIcon,
|
||||||
} from '@blocksuite/icons/rc';
|
} from '@blocksuite/icons/rc';
|
||||||
import { useLiveData, useService, useServices } from '@toeverything/infra';
|
import { useLiveData, useServices } from '@toeverything/infra';
|
||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
import { TagRenameSubMenu } from './dialog';
|
import { TagRenameSubMenu } from './dialog';
|
||||||
@@ -219,10 +218,6 @@ export const useExplorerTagNodeOperationsMenu = (
|
|||||||
}
|
}
|
||||||
): NodeOperation[] => {
|
): NodeOperation[] => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const featureFlagService = useService(FeatureFlagService);
|
|
||||||
const enableMultiView = useLiveData(
|
|
||||||
featureFlagService.flags.enable_multi_view.$
|
|
||||||
);
|
|
||||||
const {
|
const {
|
||||||
favorite,
|
favorite,
|
||||||
handleNewDoc,
|
handleNewDoc,
|
||||||
@@ -266,7 +261,7 @@ export const useExplorerTagNodeOperationsMenu = (
|
|||||||
</MenuItem>
|
</MenuItem>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
...(BUILD_CONFIG.isElectron && enableMultiView
|
...(BUILD_CONFIG.isElectron
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
index: 100,
|
index: 100,
|
||||||
@@ -312,7 +307,6 @@ export const useExplorerTagNodeOperationsMenu = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
enableMultiView,
|
|
||||||
favorite,
|
favorite,
|
||||||
handleChangeNameOrColor,
|
handleChangeNameOrColor,
|
||||||
handleMoveToTrash,
|
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 { ResizePanel } from '@affine/component/resize-panel';
|
||||||
import { useAppSettingHelper } from '@affine/core/components/hooks/affine/use-app-setting-helper';
|
import { useAppSettingHelper } from '@affine/core/components/hooks/affine/use-app-setting-helper';
|
||||||
import { NavigateContext } from '@affine/core/components/hooks/use-navigate-helper';
|
import { NavigateContext } from '@affine/core/components/hooks/use-navigate-helper';
|
||||||
import { WorkspaceNavigator } from '@affine/core/components/workspace-selector';
|
import { WorkspaceNavigator } from '@affine/core/components/workspace-selector';
|
||||||
|
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||||
import { useI18n } from '@affine/i18n';
|
import { useI18n } from '@affine/i18n';
|
||||||
import {
|
import {
|
||||||
useLiveData,
|
useLiveData,
|
||||||
@@ -14,6 +15,8 @@ import { debounce } from 'lodash-es';
|
|||||||
import type { PropsWithChildren, ReactElement } from 'react';
|
import type { PropsWithChildren, ReactElement } from 'react';
|
||||||
import { useCallback, useContext, useEffect, useMemo, useState } 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 { WorkspaceService } from '../../workspace';
|
||||||
import { AppSidebarService } from '../services/app-sidebar';
|
import { AppSidebarService } from '../services/app-sidebar';
|
||||||
import * as styles from './fallback.css';
|
import * as styles from './fallback.css';
|
||||||
@@ -44,6 +47,7 @@ export function AppSidebar({ children }: PropsWithChildren) {
|
|||||||
const clientBorder = appSettings.clientBorder;
|
const clientBorder = appSettings.clientBorder;
|
||||||
|
|
||||||
const appSidebarService = useService(AppSidebarService).sidebar;
|
const appSidebarService = useService(AppSidebarService).sidebar;
|
||||||
|
const workbenchService = useService(WorkbenchService).workbench;
|
||||||
|
|
||||||
const open = useLiveData(appSidebarService.open$);
|
const open = useLiveData(appSidebarService.open$);
|
||||||
const width = useLiveData(appSidebarService.width$);
|
const width = useLiveData(appSidebarService.width$);
|
||||||
@@ -147,6 +151,31 @@ export function AppSidebar({ children }: PropsWithChildren) {
|
|||||||
};
|
};
|
||||||
}, [appSidebarService, resizing, sidebarState, width]);
|
}, [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) {
|
if (!initialized) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -154,6 +183,7 @@ export function AppSidebar({ children }: PropsWithChildren) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ResizePanel
|
<ResizePanel
|
||||||
|
resizeHandleDropTargetOptions={resizeHandleDropTargetOptions}
|
||||||
floating={
|
floating={
|
||||||
sidebarState === 'floating' || sidebarState === 'floating-with-mask'
|
sidebarState === 'floating' || sidebarState === 'floating-with-mask'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
type MouseEventHandler,
|
type MouseEventHandler,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
||||||
@@ -62,71 +63,24 @@ const tabCanDrop =
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const WorkbenchTab = ({
|
const WorkbenchView = ({
|
||||||
workbench,
|
workbench,
|
||||||
active: tabActive,
|
view,
|
||||||
|
activeViewIndex,
|
||||||
tabsLength,
|
tabsLength,
|
||||||
dnd,
|
viewIdx,
|
||||||
onDrop,
|
tabActive,
|
||||||
}: {
|
}: {
|
||||||
workbench: TabStatus;
|
workbench: TabStatus;
|
||||||
active: boolean;
|
view: TabStatus['views'][number];
|
||||||
|
activeViewIndex: number;
|
||||||
tabsLength: number;
|
tabsLength: number;
|
||||||
|
viewIdx: number;
|
||||||
|
tabActive: boolean;
|
||||||
dnd?: boolean;
|
dnd?: boolean;
|
||||||
onDrop?: (data: DropTargetDropEvent<AffineDNDData>) => void;
|
|
||||||
}) => {
|
}) => {
|
||||||
useServiceOptional(DesktopStateSynchronizer);
|
|
||||||
const tabsHeaderService = useService(AppTabsHeaderService);
|
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(
|
const onActivateView = useAsyncCallback(
|
||||||
async (viewIdx: number) => {
|
async (viewIdx: number) => {
|
||||||
if (viewIdx === activeViewIndex && tabActive) {
|
if (viewIdx === activeViewIndex && tabActive) {
|
||||||
@@ -147,6 +101,15 @@ const WorkbenchTab = ({
|
|||||||
},
|
},
|
||||||
[activeViewIndex, tabActive, tabsHeaderService, workbench.id]
|
[activeViewIndex, tabActive, tabsHeaderService, workbench.id]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleClick: MouseEventHandler = useCatchEventCallback(
|
||||||
|
async e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onActivateView(viewIdx);
|
||||||
|
},
|
||||||
|
[onActivateView, viewIdx]
|
||||||
|
);
|
||||||
|
|
||||||
const handleAuxClick: MouseEventHandler = useCatchEventCallback(
|
const handleAuxClick: MouseEventHandler = useCatchEventCallback(
|
||||||
async e => {
|
async e => {
|
||||||
if (e.button === 1) {
|
if (e.button === 1) {
|
||||||
@@ -160,6 +123,106 @@ const WorkbenchTab = ({
|
|||||||
[tabsHeaderService, workbench.id]
|
[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 () => {
|
const handleCloseTab = useCatchEventCallback(async () => {
|
||||||
await tabsHeaderService.closeTab?.(workbench.id);
|
await tabsHeaderService.closeTab?.(workbench.id);
|
||||||
track.$.appTabsHeader.$.tabAction({
|
track.$.appTabsHeader.$.tabAction({
|
||||||
@@ -175,11 +238,11 @@ const WorkbenchTab = ({
|
|||||||
},
|
},
|
||||||
onDrop,
|
onDrop,
|
||||||
dropEffect: 'move',
|
dropEffect: 'move',
|
||||||
canDrop: tabCanDrop(workbench),
|
canDrop: dnd ? tabCanDrop(workbench) : false,
|
||||||
isSticky: true,
|
isSticky: true,
|
||||||
allowExternal: true,
|
allowExternal: true,
|
||||||
}),
|
}),
|
||||||
[onDrop, workbench]
|
[dnd, onDrop, workbench]
|
||||||
);
|
);
|
||||||
|
|
||||||
const { dragRef } = useDraggable<AffineDNDData>(() => {
|
const { dragRef } = useDraggable<AffineDNDData>(() => {
|
||||||
@@ -251,37 +314,15 @@ const WorkbenchTab = ({
|
|||||||
{workbench.views.map((view, viewIdx) => {
|
{workbench.views.map((view, viewIdx) => {
|
||||||
return (
|
return (
|
||||||
<Fragment key={view.id}>
|
<Fragment key={view.id}>
|
||||||
<button
|
<WorkbenchView
|
||||||
key={view.id}
|
workbench={workbench}
|
||||||
data-testid="split-view-label"
|
view={view}
|
||||||
className={styles.splitViewLabel}
|
activeViewIndex={activeViewIndex}
|
||||||
data-active={activeViewIndex === viewIdx && tabActive}
|
tabsLength={workbench.views.length}
|
||||||
onContextMenu={() => {
|
viewIdx={viewIdx}
|
||||||
onContextMenu(viewIdx);
|
tabActive={tabActive}
|
||||||
}}
|
dnd={dnd}
|
||||||
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>
|
|
||||||
|
|
||||||
{viewIdx !== workbench.views.length - 1 ? (
|
{viewIdx !== workbench.views.length - 1 ? (
|
||||||
<div className={styles.splitViewSeparator} />
|
<div className={styles.splitViewSeparator} />
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export class DndService extends Service {
|
|||||||
isDropEvent?: boolean
|
isDropEvent?: boolean
|
||||||
) => {
|
) => {
|
||||||
if (!isDropEvent) {
|
if (!isDropEvent) {
|
||||||
return {};
|
return this.resolveBlocksuiteExternalData(args.source) || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
let resolved: AffineDNDData['draggable'] | null = null;
|
let resolved: AffineDNDData['draggable'] | null = null;
|
||||||
@@ -168,24 +168,30 @@ export class DndService extends Service {
|
|||||||
if (!dndAPI) {
|
if (!dndAPI) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const encoded = source.getStringData(dndAPI.mimeType);
|
|
||||||
if (!encoded) {
|
if (source.types.includes(dndAPI.mimeType)) {
|
||||||
return null;
|
const from = {
|
||||||
}
|
|
||||||
const snapshot = dndAPI.decodeSnapshot(encoded);
|
|
||||||
if (!snapshot) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const entity = this.resolveBlockSnapshot(snapshot);
|
|
||||||
if (!entity) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
entity,
|
|
||||||
from: {
|
|
||||||
at: 'blocksuite-editor',
|
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 => {
|
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 { IsFavoriteIcon } from '@affine/core/components/pure/icons';
|
||||||
import { CollectionService } from '@affine/core/modules/collection';
|
import { CollectionService } from '@affine/core/modules/collection';
|
||||||
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
|
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
|
||||||
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
|
||||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||||
import { WorkspaceService } from '@affine/core/modules/workspace';
|
import { WorkspaceService } from '@affine/core/modules/workspace';
|
||||||
import { useI18n } from '@affine/i18n';
|
import { useI18n } from '@affine/i18n';
|
||||||
@@ -37,13 +36,11 @@ export const useExplorerCollectionNodeOperations = (
|
|||||||
workspaceService,
|
workspaceService,
|
||||||
collectionService,
|
collectionService,
|
||||||
compatibleFavoriteItemsAdapter,
|
compatibleFavoriteItemsAdapter,
|
||||||
featureFlagService,
|
|
||||||
} = useServices({
|
} = useServices({
|
||||||
WorkbenchService,
|
WorkbenchService,
|
||||||
WorkspaceService,
|
WorkspaceService,
|
||||||
CollectionService,
|
CollectionService,
|
||||||
CompatibleFavoriteItemsAdapter,
|
CompatibleFavoriteItemsAdapter,
|
||||||
FeatureFlagService,
|
|
||||||
});
|
});
|
||||||
const deleteInfo = useDeleteCollectionInfo();
|
const deleteInfo = useDeleteCollectionInfo();
|
||||||
|
|
||||||
@@ -51,9 +48,6 @@ export const useExplorerCollectionNodeOperations = (
|
|||||||
workspaceService.workspace.docCollection
|
workspaceService.workspace.docCollection
|
||||||
);
|
);
|
||||||
|
|
||||||
const enableMultiView = useLiveData(
|
|
||||||
featureFlagService.flags.enable_multi_view.$
|
|
||||||
);
|
|
||||||
const favorite = useLiveData(
|
const favorite = useLiveData(
|
||||||
useMemo(
|
useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -173,7 +167,7 @@ export const useExplorerCollectionNodeOperations = (
|
|||||||
</MenuItem>
|
</MenuItem>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
...(BUILD_CONFIG.isElectron && enableMultiView
|
...(BUILD_CONFIG.isElectron
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
index: 99,
|
index: 99,
|
||||||
@@ -206,7 +200,6 @@ export const useExplorerCollectionNodeOperations = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
enableMultiView,
|
|
||||||
favorite,
|
favorite,
|
||||||
handleAddDocToCollection,
|
handleAddDocToCollection,
|
||||||
handleDeleteCollection,
|
handleDeleteCollection,
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hoo
|
|||||||
import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
|
import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
|
||||||
import { DocsService } from '@affine/core/modules/doc';
|
import { DocsService } from '@affine/core/modules/doc';
|
||||||
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
|
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
|
||||||
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
|
||||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||||
import { WorkspaceService } from '@affine/core/modules/workspace';
|
import { WorkspaceService } from '@affine/core/modules/workspace';
|
||||||
import { useI18n } from '@affine/i18n';
|
import { useI18n } from '@affine/i18n';
|
||||||
@@ -43,17 +42,12 @@ export const useExplorerDocNodeOperations = (
|
|||||||
workspaceService,
|
workspaceService,
|
||||||
docsService,
|
docsService,
|
||||||
compatibleFavoriteItemsAdapter,
|
compatibleFavoriteItemsAdapter,
|
||||||
featureFlagService,
|
|
||||||
} = useServices({
|
} = useServices({
|
||||||
DocsService,
|
DocsService,
|
||||||
WorkbenchService,
|
WorkbenchService,
|
||||||
WorkspaceService,
|
WorkspaceService,
|
||||||
CompatibleFavoriteItemsAdapter,
|
CompatibleFavoriteItemsAdapter,
|
||||||
FeatureFlagService,
|
|
||||||
});
|
});
|
||||||
const enableMultiView = useLiveData(
|
|
||||||
featureFlagService.flags.enable_multi_view.$
|
|
||||||
);
|
|
||||||
const { openConfirmModal } = useConfirmModal();
|
const { openConfirmModal } = useConfirmModal();
|
||||||
|
|
||||||
const docRecord = useLiveData(docsService.list.doc$(docId));
|
const docRecord = useLiveData(docsService.list.doc$(docId));
|
||||||
@@ -188,7 +182,7 @@ export const useExplorerDocNodeOperations = (
|
|||||||
</MenuItem>
|
</MenuItem>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
...(BUILD_CONFIG.isElectron && enableMultiView
|
...(BUILD_CONFIG.isElectron
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
index: 100,
|
index: 100,
|
||||||
@@ -234,7 +228,6 @@ export const useExplorerDocNodeOperations = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
enableMultiView,
|
|
||||||
favorite,
|
favorite,
|
||||||
handleAddLinkedPage,
|
handleAddLinkedPage,
|
||||||
handleDuplicate,
|
handleDuplicate,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { usePageHelper } from '@affine/core/components/blocksuite/block-suite-pa
|
|||||||
import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
|
import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
|
||||||
import { DocsService } from '@affine/core/modules/doc';
|
import { DocsService } from '@affine/core/modules/doc';
|
||||||
import { FavoriteService } from '@affine/core/modules/favorite';
|
import { FavoriteService } from '@affine/core/modules/favorite';
|
||||||
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
|
||||||
import { TagService } from '@affine/core/modules/tag';
|
import { TagService } from '@affine/core/modules/tag';
|
||||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||||
import { WorkspaceService } from '@affine/core/modules/workspace';
|
import { WorkspaceService } from '@affine/core/modules/workspace';
|
||||||
@@ -29,28 +28,19 @@ export const useExplorerTagNodeOperations = (
|
|||||||
}
|
}
|
||||||
): NodeOperation[] => {
|
): NodeOperation[] => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const {
|
const { workbenchService, workspaceService, tagService, favoriteService } =
|
||||||
workbenchService,
|
useServices({
|
||||||
workspaceService,
|
WorkbenchService,
|
||||||
tagService,
|
WorkspaceService,
|
||||||
favoriteService,
|
TagService,
|
||||||
featureFlagService,
|
DocsService,
|
||||||
} = useServices({
|
FavoriteService,
|
||||||
WorkbenchService,
|
});
|
||||||
WorkspaceService,
|
|
||||||
TagService,
|
|
||||||
DocsService,
|
|
||||||
FavoriteService,
|
|
||||||
FeatureFlagService,
|
|
||||||
});
|
|
||||||
|
|
||||||
const favorite = useLiveData(
|
const favorite = useLiveData(
|
||||||
favoriteService.favoriteList.favorite$('tag', tagId)
|
favoriteService.favoriteList.favorite$('tag', tagId)
|
||||||
);
|
);
|
||||||
const tagRecord = useLiveData(tagService.tagList.tagByTagId$(tagId));
|
const tagRecord = useLiveData(tagService.tagList.tagByTagId$(tagId));
|
||||||
const enableMultiView = useLiveData(
|
|
||||||
featureFlagService.flags.enable_multi_view.$
|
|
||||||
);
|
|
||||||
|
|
||||||
const { createPage } = usePageHelper(
|
const { createPage } = usePageHelper(
|
||||||
workspaceService.workspace.docCollection
|
workspaceService.workspace.docCollection
|
||||||
@@ -115,7 +105,7 @@ export const useExplorerTagNodeOperations = (
|
|||||||
</MenuItem>
|
</MenuItem>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
...(BUILD_CONFIG.isElectron && enableMultiView
|
...(BUILD_CONFIG.isElectron
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
index: 100,
|
index: 100,
|
||||||
@@ -161,7 +151,6 @@ export const useExplorerTagNodeOperations = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
enableMultiView,
|
|
||||||
favorite,
|
favorite,
|
||||||
handleMoveToTrash,
|
handleMoveToTrash,
|
||||||
handleNewDoc,
|
handleNewDoc,
|
||||||
|
|||||||
@@ -103,18 +103,6 @@ export const AFFINE_FLAGS = {
|
|||||||
configurable: false,
|
configurable: false,
|
||||||
defaultState: true,
|
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: {
|
enable_emoji_folder_icon: {
|
||||||
category: 'affine',
|
category: 'affine',
|
||||||
displayName:
|
displayName:
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { View } from './view';
|
|||||||
|
|
||||||
export type WorkbenchPosition = 'beside' | 'active' | 'head' | 'tail' | number;
|
export type WorkbenchPosition = 'beside' | 'active' | 'head' | 'tail' | number;
|
||||||
|
|
||||||
type WorkbenchOpenOptions = {
|
export type WorkbenchOpenOptions = {
|
||||||
at?: WorkbenchPosition | 'new-tab';
|
at?: WorkbenchPosition | 'new-tab';
|
||||||
replaceHistory?: boolean;
|
replaceHistory?: boolean;
|
||||||
show?: boolean; // only for new tab
|
show?: boolean; // only for new tab
|
||||||
@@ -52,16 +52,24 @@ export class Workbench extends Entity {
|
|||||||
});
|
});
|
||||||
sidebarOpen$ = new LiveData(false);
|
sidebarOpen$ = new LiveData(false);
|
||||||
|
|
||||||
active(index: number) {
|
active(index: number | View) {
|
||||||
index = Math.max(0, Math.min(index, this.views$.value.length - 1));
|
if (typeof index === 'number') {
|
||||||
this.activeViewIndex$.next(index);
|
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) {
|
updateBasename(basename: string) {
|
||||||
this.basename$.next(basename);
|
this.basename$.next(basename);
|
||||||
}
|
}
|
||||||
|
|
||||||
createView(at: WorkbenchPosition = 'beside', defaultLocation: To) {
|
createView(
|
||||||
|
at: WorkbenchPosition = 'beside',
|
||||||
|
defaultLocation: To,
|
||||||
|
active = true
|
||||||
|
) {
|
||||||
const view = this.framework.createEntity(View, {
|
const view = this.framework.createEntity(View, {
|
||||||
id: nanoid(),
|
id: nanoid(),
|
||||||
defaultLocation,
|
defaultLocation,
|
||||||
@@ -70,7 +78,9 @@ export class Workbench extends Entity {
|
|||||||
newViews.splice(this.indexAt(at), 0, view);
|
newViews.splice(this.indexAt(at), 0, view);
|
||||||
this.views$.next(newViews);
|
this.views$.next(newViews);
|
||||||
const index = newViews.indexOf(view);
|
const index = newViews.indexOf(view);
|
||||||
this.active(index);
|
if (active) {
|
||||||
|
this.active(index);
|
||||||
|
}
|
||||||
return index;
|
return index;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +105,7 @@ export class Workbench extends Entity {
|
|||||||
const { at = 'active', replaceHistory = false } = option;
|
const { at = 'active', replaceHistory = false } = option;
|
||||||
let view = this.viewAt(at);
|
let view = this.viewAt(at);
|
||||||
if (!view) {
|
if (!view) {
|
||||||
const newIndex = this.createView(at, to);
|
const newIndex = this.createView(at, to, option.show);
|
||||||
view = this.viewAt(newIndex);
|
view = this.viewAt(newIndex);
|
||||||
if (!view) {
|
if (!view) {
|
||||||
throw new Unreachable();
|
throw new Unreachable();
|
||||||
|
|||||||
@@ -28,12 +28,9 @@ export class DesktopStateSynchronizer extends Service {
|
|||||||
event.type === 'open-in-split-view' &&
|
event.type === 'open-in-split-view' &&
|
||||||
event.payload.tabId === appInfo?.viewId
|
event.payload.tabId === appInfo?.viewId
|
||||||
) {
|
) {
|
||||||
const to =
|
workbench.openAll({
|
||||||
event.payload.view?.path ??
|
|
||||||
workbench.activeView$.value?.location$.value;
|
|
||||||
|
|
||||||
workbench.open(to, {
|
|
||||||
at: 'beside',
|
at: 'beside',
|
||||||
|
show: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,34 +23,57 @@ export const menuTrigger = style({
|
|||||||
height: 0,
|
height: 0,
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const indicator = style({
|
export const indicator = style({
|
||||||
width: 29,
|
padding: '6px 20px',
|
||||||
height: 15,
|
height: 15,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
gap: 3,
|
||||||
cursor: 'grab',
|
cursor: 'grab',
|
||||||
['WebkitAppRegion' as string]: 'no-drag',
|
['WebkitAppRegion' as string]: 'no-drag',
|
||||||
color: cssVar('placeholderColor'),
|
color: cssVar('placeholderColor'),
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
|
||||||
selectors: {
|
selectors: {
|
||||||
'&:hover, &:active, &[data-active="true"]': {
|
'&:hover, &[data-active="true"], &[data-dragging="true"]': {
|
||||||
color: cssVar('brandColor'),
|
color: cssVar('brandColor'),
|
||||||
},
|
},
|
||||||
},
|
'&[data-dragging="true"]': {
|
||||||
});
|
gap: 4,
|
||||||
|
},
|
||||||
export const indicatorInner = style({
|
},
|
||||||
width: 16,
|
});
|
||||||
height: 3,
|
|
||||||
borderRadius: 10,
|
export const indicatorDot = style({
|
||||||
backgroundColor: 'currentColor',
|
width: 4,
|
||||||
transition: 'all 0.5s cubic-bezier(0.16, 1, 0.3, 1)',
|
height: 4,
|
||||||
|
borderRadius: 2,
|
||||||
selectors: {
|
backgroundColor: 'currentColor',
|
||||||
'[data-is-dragging="true"] &': {
|
transition: 'all 0.2s',
|
||||||
width: 24,
|
selectors: {
|
||||||
height: 2,
|
[`${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 type { HTMLAttributes, MouseEventHandler } from 'react';
|
||||||
import { forwardRef, memo, useCallback, useMemo, useState } from 'react';
|
import { forwardRef, memo, useCallback, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import type { View } from '../../entities/view';
|
||||||
import * as styles from './indicator.css';
|
import * as styles from './indicator.css';
|
||||||
|
|
||||||
export interface SplitViewMenuProps extends HTMLAttributes<HTMLDivElement> {
|
export interface SplitViewDragHandleProps
|
||||||
|
extends HTMLAttributes<HTMLDivElement> {
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
|
dragging?: boolean;
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
onOpenMenu?: () => void;
|
onOpenMenu?: () => void;
|
||||||
setPressed: (v: boolean) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SplitViewMenuIndicator = memo(
|
export const SplitViewDragHandle = memo(
|
||||||
forwardRef<HTMLDivElement, SplitViewMenuProps>(
|
forwardRef<HTMLDivElement, SplitViewDragHandleProps>(
|
||||||
function SplitViewMenuIndicator(
|
function SplitViewDragHandle(
|
||||||
{
|
{ className, active, open, onOpenMenu, dragging, ...attrs },
|
||||||
className,
|
|
||||||
active,
|
|
||||||
open,
|
|
||||||
setPressed,
|
|
||||||
onOpenMenu,
|
|
||||||
...attrs
|
|
||||||
}: SplitViewMenuProps,
|
|
||||||
ref
|
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(() => {
|
const onClick: MouseEventHandler = useCallback(() => {
|
||||||
!open && onOpenMenu?.();
|
!open && onOpenMenu?.();
|
||||||
}, [onOpenMenu, open]);
|
}, [onOpenMenu, open]);
|
||||||
@@ -47,13 +29,16 @@ export const SplitViewMenuIndicator = memo(
|
|||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
data-active={active}
|
data-active={active}
|
||||||
|
data-dragging={dragging}
|
||||||
data-testid="split-view-indicator"
|
data-testid="split-view-indicator"
|
||||||
className={clsx(className, styles.indicator)}
|
className={clsx(className, styles.indicator)}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onMouseDown={onMouseDown}
|
|
||||||
{...attrs}
|
{...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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -61,64 +46,69 @@ export const SplitViewMenuIndicator = memo(
|
|||||||
);
|
);
|
||||||
|
|
||||||
interface SplitViewIndicatorProps extends HTMLAttributes<HTMLDivElement> {
|
interface SplitViewIndicatorProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
isDragging?: boolean;
|
view: View;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
|
isDragging?: boolean;
|
||||||
menuItems?: React.ReactNode;
|
menuItems?: React.ReactNode;
|
||||||
// import type { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities' is not allowed
|
|
||||||
listeners?: any;
|
|
||||||
setPressed?: (pressed: boolean) => void;
|
setPressed?: (pressed: boolean) => void;
|
||||||
|
dragHandleRef?: React.RefObject<HTMLDivElement>;
|
||||||
}
|
}
|
||||||
export const SplitViewIndicator = ({
|
export const SplitViewIndicator = memo(
|
||||||
isDragging,
|
forwardRef<HTMLDivElement, SplitViewIndicatorProps>(
|
||||||
isActive,
|
function SplitViewIndicator(
|
||||||
menuItems,
|
{ isActive, menuItems, isDragging, dragHandleRef },
|
||||||
listeners,
|
ref
|
||||||
setPressed,
|
) {
|
||||||
}: SplitViewIndicatorProps) => {
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const active = isActive || isDragging;
|
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
|
||||||
|
|
||||||
// prevent menu from opening when dragging
|
// prevent menu from opening when dragging
|
||||||
const setOpenMenuManually = useCallback((open: boolean) => {
|
const setOpenMenuManually = useCallback((open: boolean) => {
|
||||||
if (open) return;
|
if (open) return;
|
||||||
setMenuOpen(open);
|
setMenuOpen(open);
|
||||||
}, []);
|
}, []);
|
||||||
const openMenu = useCallback(() => {
|
|
||||||
setMenuOpen(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const menuRootOptions = useMemo(
|
const openMenu = useCallback(() => {
|
||||||
() =>
|
setMenuOpen(true);
|
||||||
({
|
}, []);
|
||||||
open: menuOpen,
|
|
||||||
onOpenChange: setOpenMenuManually,
|
|
||||||
}) satisfies MenuProps['rootOptions'],
|
|
||||||
[menuOpen, setOpenMenuManually]
|
|
||||||
);
|
|
||||||
const menuContentOptions = useMemo(
|
|
||||||
() =>
|
|
||||||
({
|
|
||||||
align: 'center',
|
|
||||||
}) satisfies MenuProps['contentOptions'],
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
const menuRootOptions = useMemo(
|
||||||
<div data-is-dragging={isDragging} className={styles.indicatorWrapper}>
|
() =>
|
||||||
<Menu
|
({
|
||||||
contentOptions={menuContentOptions}
|
open: menuOpen,
|
||||||
items={menuItems}
|
onOpenChange: setOpenMenuManually,
|
||||||
rootOptions={menuRootOptions}
|
}) satisfies MenuProps['rootOptions'],
|
||||||
>
|
[menuOpen, setOpenMenuManually]
|
||||||
<div className={styles.menuTrigger} />
|
);
|
||||||
</Menu>
|
const menuContentOptions = useMemo(
|
||||||
<SplitViewMenuIndicator
|
() =>
|
||||||
open={menuOpen}
|
({
|
||||||
onOpenMenu={openMenu}
|
align: 'center',
|
||||||
active={active}
|
}) satisfies MenuProps['contentOptions'],
|
||||||
setPressed={setPressed}
|
[]
|
||||||
{...listeners}
|
);
|
||||||
/>
|
|
||||||
</div>
|
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 { useI18n } from '@affine/i18n';
|
||||||
import {
|
import {
|
||||||
ExpandCloseIcon,
|
CloseIcon,
|
||||||
MoveToLeftDuotoneIcon,
|
ExpandFullIcon,
|
||||||
MoveToRightDuotoneIcon,
|
InsertLeftIcon,
|
||||||
SoloViewIcon,
|
InsertRightIcon,
|
||||||
} from '@blocksuite/icons/rc';
|
} from '@blocksuite/icons/rc';
|
||||||
import { useSortable } from '@dnd-kit/sortable';
|
|
||||||
import { useLiveData, useService } from '@toeverything/infra';
|
import { useLiveData, useService } from '@toeverything/infra';
|
||||||
import { assignInlineVars } from '@vanilla-extract/dynamic';
|
import { assignInlineVars } from '@vanilla-extract/dynamic';
|
||||||
import type {
|
import { useAtom } from 'jotai';
|
||||||
Dispatch,
|
import type { HTMLAttributes, PropsWithChildren } from 'react';
|
||||||
HTMLAttributes,
|
import { memo, useCallback, useMemo } from 'react';
|
||||||
PropsWithChildren,
|
|
||||||
RefObject,
|
|
||||||
SetStateAction,
|
|
||||||
} from 'react';
|
|
||||||
import {
|
|
||||||
memo,
|
|
||||||
useCallback,
|
|
||||||
useLayoutEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
|
|
||||||
import type { View } from '../../entities/view';
|
import type { View } from '../../entities/view';
|
||||||
import { WorkbenchService } from '../../services/workbench';
|
import { WorkbenchService } from '../../services/workbench';
|
||||||
import { SplitViewIndicator } from './indicator';
|
import { SplitViewIndicator } from './indicator';
|
||||||
|
import { ResizeHandle } from './resize-handle';
|
||||||
import * as styles from './split-view.css';
|
import * as styles from './split-view.css';
|
||||||
|
import {
|
||||||
|
draggingOverViewAtom,
|
||||||
|
draggingViewAtom,
|
||||||
|
resizingViewAtom,
|
||||||
|
} from './state';
|
||||||
|
import { allowedSplitViewEntityTypes } from './types';
|
||||||
|
|
||||||
export interface SplitViewPanelProps
|
export interface SplitViewPanelProps
|
||||||
extends PropsWithChildren<HTMLAttributes<HTMLDivElement>> {
|
extends PropsWithChildren<HTMLAttributes<HTMLDivElement>> {
|
||||||
view: View;
|
view: View;
|
||||||
|
index: number;
|
||||||
resizeHandle?: React.ReactNode;
|
resizeHandle?: React.ReactNode;
|
||||||
setSlots?: Dispatch<
|
onMove: (from: number, to: number) => void;
|
||||||
SetStateAction<Record<string, RefObject<HTMLDivElement | null>>>
|
onResizing: (dxy: { x: number; y: number }) => void;
|
||||||
>;
|
draggingEntity: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SplitViewPanelContainer = ({
|
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({
|
export const SplitViewPanel = memo(function SplitViewPanel({
|
||||||
children,
|
children,
|
||||||
view,
|
view,
|
||||||
setSlots,
|
onMove,
|
||||||
|
onResizing,
|
||||||
|
draggingEntity,
|
||||||
|
index,
|
||||||
}: SplitViewPanelProps) {
|
}: SplitViewPanelProps) {
|
||||||
const [indicatorPressed, setIndicatorPressed] = useState(false);
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
const size = useLiveData(view.size$);
|
const size = useLiveData(view.size$);
|
||||||
const workbench = useService(WorkbenchService).workbench;
|
const workbench = useService(WorkbenchService).workbench;
|
||||||
|
|
||||||
const activeView = useLiveData(workbench.activeView$);
|
const activeView = useLiveData(workbench.activeView$);
|
||||||
const views = useLiveData(workbench.views$);
|
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;
|
const isActive = activeView === view;
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
const [draggingView, setDraggingView] = useAtom(draggingViewAtom);
|
||||||
if (ref.current) {
|
const [draggingOverView, setDraggingOverView] = useAtom(draggingOverViewAtom);
|
||||||
setSlots?.(slots => ({ ...slots, [view.id]: ref }));
|
const [resizingView, setResizingView] = useAtom(resizingViewAtom);
|
||||||
}
|
|
||||||
}, [setSlots, view.id]);
|
|
||||||
|
|
||||||
const style = useMemo(
|
const order = useMemo(
|
||||||
() => ({
|
() =>
|
||||||
...assignInlineVars({ '--size': size.toString() }),
|
calculateOrder(
|
||||||
}),
|
index,
|
||||||
[size]
|
draggingView?.index ?? -1,
|
||||||
);
|
draggingOverView?.index ?? -1
|
||||||
const dragStyle = useMemo(
|
),
|
||||||
() => ({
|
[index, draggingView, draggingOverView]
|
||||||
transform: `translate3d(${transform?.x ?? 0}px, 0, 0)`,
|
|
||||||
transition,
|
|
||||||
}),
|
|
||||||
[transform, transition]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<SplitViewPanelContainer
|
<SplitViewPanelContainer
|
||||||
style={style}
|
style={style}
|
||||||
data-is-dragging={isDragging}
|
data-is-dragging={dragging}
|
||||||
data-is-active={isActive && views.length > 1}
|
data-is-active={isActive && views.length > 1}
|
||||||
|
data-is-first={isFirst}
|
||||||
data-is-last={isLast}
|
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
|
<div
|
||||||
ref={setNodeRef}
|
ref={node => {
|
||||||
style={dragStyle}
|
dropTargetRef.current = node;
|
||||||
|
dragRef.current = node;
|
||||||
|
}}
|
||||||
className={styles.splitViewPanelDrag}
|
className={styles.splitViewPanelDrag}
|
||||||
{...attributes}
|
|
||||||
>
|
>
|
||||||
<div className={styles.splitViewPanelContent} ref={ref} />
|
<div draggable={false} className={styles.splitViewPanelContent}>
|
||||||
{views.length > 1 ? (
|
{children}
|
||||||
|
</div>
|
||||||
|
{views.length > 1 && onMove ? (
|
||||||
<SplitViewIndicator
|
<SplitViewIndicator
|
||||||
listeners={listeners}
|
view={view}
|
||||||
isDragging={isDragging}
|
|
||||||
isActive={isActive}
|
isActive={isActive}
|
||||||
menuItems={<SplitViewMenu view={view} />}
|
isDragging={dragging}
|
||||||
setPressed={setIndicatorPressed}
|
dragHandleRef={dragHandleRef}
|
||||||
|
menuItems={<SplitViewMenu view={view} onMove={onMove} />}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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>
|
</SplitViewPanelContainer>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const SplitViewMenu = ({ view }: { view: View }) => {
|
const SplitViewMenu = ({
|
||||||
|
view,
|
||||||
|
onMove,
|
||||||
|
}: {
|
||||||
|
view: View;
|
||||||
|
onMove: (from: number, to: number) => void;
|
||||||
|
}) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const workbench = useService(WorkbenchService).workbench;
|
const workbench = useService(WorkbenchService).workbench;
|
||||||
const views = useLiveData(workbench.views$);
|
const views = useLiveData(workbench.views$);
|
||||||
@@ -136,42 +293,39 @@ const SplitViewMenu = ({ view }: { view: View }) => {
|
|||||||
[view, workbench]
|
[view, workbench]
|
||||||
);
|
);
|
||||||
const handleMoveLeft = useCallback(() => {
|
const handleMoveLeft = useCallback(() => {
|
||||||
workbench.moveView(viewIndex, viewIndex - 1);
|
onMove(viewIndex, viewIndex - 1);
|
||||||
}, [viewIndex, workbench]);
|
}, [onMove, viewIndex]);
|
||||||
const handleMoveRight = useCallback(() => {
|
const handleMoveRight = useCallback(() => {
|
||||||
workbench.moveView(viewIndex, viewIndex + 1);
|
onMove(viewIndex, viewIndex + 1);
|
||||||
}, [viewIndex, workbench]);
|
}, [onMove, viewIndex]);
|
||||||
const handleCloseOthers = useCallback(() => {
|
const handleCloseOthers = useCallback(() => {
|
||||||
workbench.closeOthers(view);
|
workbench.closeOthers(view);
|
||||||
}, [view, workbench]);
|
}, [view, workbench]);
|
||||||
|
|
||||||
const CloseItem =
|
const CloseItem =
|
||||||
views.length > 1 ? (
|
views.length > 1 ? (
|
||||||
<MenuItem prefixIcon={<ExpandCloseIcon />} onClick={handleClose}>
|
<MenuItem prefixIcon={<CloseIcon />} onClick={handleClose}>
|
||||||
{t['com.affine.workbench.split-view-menu.close']()}
|
{t['com.affine.workbench.split-view-menu.close']()}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
const MoveLeftItem =
|
const MoveLeftItem =
|
||||||
viewIndex > 0 && views.length > 1 ? (
|
viewIndex > 0 && views.length > 1 ? (
|
||||||
<MenuItem onClick={handleMoveLeft} prefixIcon={<MoveToLeftDuotoneIcon />}>
|
<MenuItem onClick={handleMoveLeft} prefixIcon={<InsertRightIcon />}>
|
||||||
{t['com.affine.workbench.split-view-menu.move-left']()}
|
{t['com.affine.workbench.split-view-menu.move-left']()}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
const FullScreenItem =
|
const FullScreenItem =
|
||||||
views.length > 1 ? (
|
views.length > 1 ? (
|
||||||
<MenuItem onClick={handleCloseOthers} prefixIcon={<SoloViewIcon />}>
|
<MenuItem onClick={handleCloseOthers} prefixIcon={<ExpandFullIcon />}>
|
||||||
{t['com.affine.workbench.split-view-menu.keep-this-one']()}
|
{t['com.affine.workbench.split-view-menu.keep-this-one']()}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
const MoveRightItem =
|
const MoveRightItem =
|
||||||
viewIndex < views.length - 1 ? (
|
viewIndex < views.length - 1 ? (
|
||||||
<MenuItem
|
<MenuItem onClick={handleMoveRight} prefixIcon={<InsertLeftIcon />}>
|
||||||
onClick={handleMoveRight}
|
|
||||||
prefixIcon={<MoveToRightDuotoneIcon />}
|
|
||||||
>
|
|
||||||
{t['com.affine.workbench.split-view-menu.move-right']()}
|
{t['com.affine.workbench.split-view-menu.move-right']()}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
) : null;
|
) : 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 type { HTMLAttributes } from 'react';
|
||||||
import { useCallback } 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 * as styles from './split-view.css';
|
||||||
|
import { draggingOverResizeHandleAtom } from './state';
|
||||||
|
import { allowedSplitViewEntityTypes } from './types';
|
||||||
|
|
||||||
interface ResizeHandleProps extends HTMLAttributes<HTMLDivElement> {
|
interface ResizeHandleProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
resizing: boolean;
|
state: 'resizing' | 'drop-indicator' | 'idle';
|
||||||
onResizeStart: () => void;
|
edge: 'left' | 'right';
|
||||||
onResizeEnd: () => void;
|
view: View;
|
||||||
onResizing: (offset: { x: number; y: number }) => void;
|
onResizeStart?: () => void;
|
||||||
|
onResizeEnd?: () => void;
|
||||||
|
onResizing?: (offset: { x: number; y: number }) => void;
|
||||||
}
|
}
|
||||||
export const ResizeHandle = ({
|
export const ResizeHandle = ({
|
||||||
resizing,
|
state,
|
||||||
|
view,
|
||||||
|
edge,
|
||||||
onResizing,
|
onResizing,
|
||||||
onResizeStart,
|
onResizeStart,
|
||||||
onResizeEnd,
|
onResizeEnd,
|
||||||
}: ResizeHandleProps) => {
|
}: 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
|
// TODO(@catsjuice): touch support
|
||||||
const onMouseDown = useCallback(
|
const onMouseDown = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
|
if (!onResizing || !onResizeStart || !onResizeEnd) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
onResizeStart();
|
onResizeStart?.();
|
||||||
const prevPos = { x: e.clientX, y: e.clientY };
|
const prevPos = { x: e.clientX, y: e.clientY };
|
||||||
|
|
||||||
function onMouseMove(e: MouseEvent) {
|
function onMouseMove(e: MouseEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const dx = e.clientX - prevPos.x;
|
const dx = e.clientX - prevPos.x;
|
||||||
const dy = e.clientY - prevPos.y;
|
const dy = e.clientY - prevPos.y;
|
||||||
onResizing({ x: dx, y: dy });
|
onResizing?.({ x: dx, y: dy });
|
||||||
prevPos.x = e.clientX;
|
prevPos.x = e.clientX;
|
||||||
prevPos.y = e.clientY;
|
prevPos.y = e.clientY;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onMouseUp(e: MouseEvent) {
|
function onMouseUp(e: MouseEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onResizeEnd();
|
onResizeEnd?.();
|
||||||
document.removeEventListener('mousemove', onMouseMove);
|
document.removeEventListener('mousemove', onMouseMove);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,10 +90,21 @@ export const ResizeHandle = ({
|
|||||||
[onResizeEnd, onResizeStart, onResizing]
|
[onResizeEnd, onResizeStart, onResizing]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const canResize =
|
||||||
|
state === 'idle' &&
|
||||||
|
!(isLast && edge === 'right') &&
|
||||||
|
!(isFirst && edge === 'left');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={dropTargetRef}
|
||||||
|
data-edge={edge}
|
||||||
onMouseDown={onMouseDown}
|
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}
|
className={styles.resizeHandle}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,55 +1,55 @@
|
|||||||
import { cssVar } from '@toeverything/theme';
|
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||||
import { createVar, style } from '@vanilla-extract/css';
|
import { createVar, fallbackVar, keyframes, style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
const gap = createVar();
|
const gap = createVar();
|
||||||
const borderRadius = 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({
|
const expandDropIndicator = keyframes({
|
||||||
vars: {
|
from: {
|
||||||
[gap]: '0px',
|
vars: {
|
||||||
[borderRadius]: '0px',
|
[resizeHandleWidth]: '30px',
|
||||||
},
|
[dropIndicatorWidth]: '3px',
|
||||||
display: 'flex',
|
[dropIndicatorOpacity]: '1',
|
||||||
flexDirection: 'row',
|
[dropIndicatorRadius]: '10px',
|
||||||
position: 'relative',
|
|
||||||
borderRadius,
|
|
||||||
gap,
|
|
||||||
|
|
||||||
selectors: {
|
|
||||||
'&[data-client-border="true"]': {
|
|
||||||
vars: {
|
|
||||||
[gap]: '8px',
|
|
||||||
[borderRadius]: '6px',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
'&[data-orientation="vertical"]': {
|
},
|
||||||
flexDirection: 'column',
|
to: {
|
||||||
|
vars: {
|
||||||
|
[resizeHandleWidth]: '300px',
|
||||||
|
[dropIndicatorWidth]: '100%',
|
||||||
|
[dropIndicatorOpacity]: '0.15',
|
||||||
|
[dropIndicatorRadius]: '4px',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const splitViewPanel = style({
|
export const splitViewPanel = style({
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
flexGrow: 'var(--size, 1)',
|
flexGrow: fallbackVar(size, '1'),
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
borderRadius: 'inherit',
|
order: panelOrder,
|
||||||
|
display: 'flex',
|
||||||
|
|
||||||
selectors: {
|
selectors: {
|
||||||
'[data-orientation="vertical"] &': {
|
'[data-client-border="false"] &[data-is-first="true"]': {
|
||||||
height: 0,
|
borderTopLeftRadius: borderRadius,
|
||||||
},
|
|
||||||
'[data-orientation="horizontal"] &': {
|
|
||||||
width: 0,
|
|
||||||
},
|
},
|
||||||
'[data-client-border="false"] &:not([data-is-last="true"]):not([data-is-dragging="true"])':
|
'[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"] &': {
|
'[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%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
borderRadius: 'inherit',
|
borderRadius: 'inherit',
|
||||||
|
transition: 'opacity 0.2s',
|
||||||
|
|
||||||
selectors: {
|
selectors: {
|
||||||
'&::after': {
|
'&::after': {
|
||||||
@@ -73,8 +74,12 @@ export const splitViewPanelDrag = style({
|
|||||||
transition: 'box-shadow 0.5s cubic-bezier(0.16, 1, 0.3, 1)',
|
transition: 'box-shadow 0.5s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||||
},
|
},
|
||||||
|
|
||||||
'[data-is-dragging="true"] &::after': {
|
'[data-is-active="true"] &::after': {
|
||||||
boxShadow: `inset 0 0 0 2px ${cssVar('brandColor')}`,
|
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',
|
position: 'absolute',
|
||||||
top: 0,
|
top: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
right: -5,
|
width: resizeHandleWidth,
|
||||||
width: 10,
|
|
||||||
// to make sure it's above all-pages's header
|
// to make sure it's above all-pages's header
|
||||||
zIndex: 3,
|
zIndex: 5,
|
||||||
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'stretch',
|
alignItems: 'stretch',
|
||||||
cursor: 'col-resize',
|
|
||||||
|
|
||||||
selectors: {
|
selectors: {
|
||||||
'[data-client-border="true"] &': {
|
'&[data-can-resize="false"]:not([data-state="drop-indicator"])': {
|
||||||
right: `calc(-5px - ${gap} / 2)`,
|
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"] &`]: {
|
[`.${splitViewPanel}[data-is-dragging="true"] &`]: {
|
||||||
display: 'none',
|
display: 'none',
|
||||||
},
|
},
|
||||||
|
|
||||||
// horizontal
|
'&[data-state="drop-indicator"][data-dragging-over="true"]': {
|
||||||
'[data-orientation="horizontal"] &::before, [data-orientation="horizontal"] &::after':
|
animationName: expandDropIndicator,
|
||||||
{
|
animationDuration: '0.5s',
|
||||||
content: '""',
|
animationDelay: '1s',
|
||||||
width: 2,
|
animationFillMode: 'forwards',
|
||||||
position: 'absolute',
|
vars: {
|
||||||
height: '100%',
|
[dropIndicatorOpacity]: '1',
|
||||||
background: 'transparent',
|
[dropIndicatorWidth]: '3px',
|
||||||
transition: 'background 0.1s',
|
|
||||||
borderRadius: 10,
|
|
||||||
},
|
},
|
||||||
'[data-orientation="horizontal"] &[data-resizing]::before, [data-orientation="horizontal"] &[data-resizing]::after':
|
|
||||||
{
|
|
||||||
width: 3,
|
|
||||||
},
|
|
||||||
|
|
||||||
'&:hover::before, &[data-resizing]::before': {
|
|
||||||
background: cssVar('brandColor'),
|
|
||||||
},
|
},
|
||||||
'&:hover::after, &[data-resizing]::after': {
|
|
||||||
boxShadow: `0px 12px 21px 4px ${cssVar('brandColor')}`,
|
'&::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,
|
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 { useAppSettingHelper } from '@affine/core/components/hooks/affine/use-app-setting-helper';
|
||||||
import type { DragEndEvent } from '@dnd-kit/core';
|
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||||
import {
|
|
||||||
closestCenter,
|
|
||||||
DndContext,
|
|
||||||
PointerSensor,
|
|
||||||
useSensor,
|
|
||||||
useSensors,
|
|
||||||
} from '@dnd-kit/core';
|
|
||||||
import {
|
|
||||||
horizontalListSortingStrategy,
|
|
||||||
SortableContext,
|
|
||||||
} from '@dnd-kit/sortable';
|
|
||||||
import { useService } from '@toeverything/infra';
|
import { useService } from '@toeverything/infra';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import type { HTMLAttributes, RefObject } from 'react';
|
import { useSetAtom } from 'jotai';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import type { HTMLAttributes } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import type { View } from '../../entities/view';
|
import type { View } from '../../entities/view';
|
||||||
import { WorkbenchService } from '../../services/workbench';
|
import { WorkbenchService } from '../../services/workbench';
|
||||||
import { SplitViewPanel } from './panel';
|
import { SplitViewPanel } from './panel';
|
||||||
import { ResizeHandle } from './resize-handle';
|
|
||||||
import * as styles from './split-view.css';
|
import * as styles from './split-view.css';
|
||||||
|
import { draggingOverResizeHandleAtom } from './state';
|
||||||
|
import { allowedSplitViewEntityTypes, inferToFromEntity } from './types';
|
||||||
|
|
||||||
export interface SplitViewProps extends HTMLAttributes<HTMLDivElement> {
|
export interface SplitViewProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
/**
|
/**
|
||||||
@@ -30,12 +21,10 @@ export interface SplitViewProps extends HTMLAttributes<HTMLDivElement> {
|
|||||||
*/
|
*/
|
||||||
orientation?: 'horizontal' | 'vertical';
|
orientation?: 'horizontal' | 'vertical';
|
||||||
views: View[];
|
views: View[];
|
||||||
renderer: (item: View, index: number) => React.ReactNode;
|
renderer: (item: View) => React.ReactNode;
|
||||||
onMove?: (from: number, to: number) => void;
|
onMove?: (from: number, to: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SlotsMap = Record<View['id'], RefObject<HTMLDivElement | null>>;
|
|
||||||
// TODO(@catsjuice): vertical orientation support
|
|
||||||
export const SplitView = ({
|
export const SplitView = ({
|
||||||
orientation = 'horizontal',
|
orientation = 'horizontal',
|
||||||
className,
|
className,
|
||||||
@@ -45,29 +34,27 @@ export const SplitView = ({
|
|||||||
...attrs
|
...attrs
|
||||||
}: SplitViewProps) => {
|
}: SplitViewProps) => {
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
const [slots, setSlots] = useState<SlotsMap>({});
|
|
||||||
const [resizingViewId, setResizingViewId] = useState<View['id'] | null>(null);
|
|
||||||
const { appSettings } = useAppSettingHelper();
|
const { appSettings } = useAppSettingHelper();
|
||||||
const workbench = useService(WorkbenchService).workbench;
|
const workbench = useService(WorkbenchService).workbench;
|
||||||
|
|
||||||
// blocksuite's lit host element has an issue on remounting.
|
// workaround: blocksuite's lit host element has an issue on remounting.
|
||||||
// Add a workaround here to force remounting after dropping.
|
// we do not want the view to change its render ordering here after reordering
|
||||||
const [visible, setVisibility] = useState(true);
|
// instead we use a local state to store the views + its order to avoid remounting
|
||||||
|
const [localViewsState, setLocalViewsState] = useState<View[]>(views);
|
||||||
|
|
||||||
const sensors = useSensors(
|
useLayoutEffect(() => {
|
||||||
useSensor(
|
setLocalViewsState(oldViews => {
|
||||||
PointerSensor,
|
let newViews = oldViews.filter(v => views.includes(v));
|
||||||
useMemo(
|
|
||||||
/* avoid re-rendering */
|
for (const view of views) {
|
||||||
() => ({
|
if (!newViews.includes(view)) {
|
||||||
activationConstraint: {
|
newViews.push(view);
|
||||||
distance: 0,
|
}
|
||||||
},
|
}
|
||||||
}),
|
|
||||||
[]
|
return newViews;
|
||||||
)
|
});
|
||||||
)
|
}, [views]);
|
||||||
);
|
|
||||||
|
|
||||||
const onResizing = useCallback(
|
const onResizing = useCallback(
|
||||||
(index: number, { x, y }: { x: number; y: number }) => {
|
(index: number, { x, y }: { x: number; y: number }) => {
|
||||||
@@ -85,45 +72,101 @@ export const SplitView = ({
|
|||||||
[orientation, workbench]
|
[orientation, workbench]
|
||||||
);
|
);
|
||||||
|
|
||||||
const resizeHandleRenderer = useCallback(
|
const handleOnMove = useCallback(
|
||||||
(view: View, index: number) =>
|
(from: number, to: number) => {
|
||||||
index < views.length - 1 ? (
|
onMove?.(from, to);
|
||||||
<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);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[onMove, views]
|
[onMove]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
const [draggingEntity, setDraggingEntity] = useState(false);
|
||||||
if (!visible) {
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
setVisibility(true);
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
return () => {
|
const setDraggingOverResizeHandle = useSetAtom(draggingOverResizeHandleAtom);
|
||||||
clearTimeout(timeoutId);
|
|
||||||
};
|
useDndMonitor<AffineDNDData>(() => {
|
||||||
}
|
return {
|
||||||
return;
|
// todo(@pengx17): external data for monitor is not supported yet
|
||||||
}, [visible]);
|
// 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -133,29 +176,19 @@ export const SplitView = ({
|
|||||||
data-client-border={appSettings.clientBorder}
|
data-client-border={appSettings.clientBorder}
|
||||||
{...attrs}
|
{...attrs}
|
||||||
>
|
>
|
||||||
<DndContext
|
{localViewsState.map(view => {
|
||||||
sensors={sensors}
|
const order = views.indexOf(view);
|
||||||
collisionDetection={closestCenter}
|
return (
|
||||||
onDragEnd={handleDragEnd}
|
<SplitViewPanel
|
||||||
>
|
view={view}
|
||||||
<SortableContext items={views} strategy={horizontalListSortingStrategy}>
|
index={order}
|
||||||
{views.map((view, index) =>
|
key={view.id}
|
||||||
visible ? (
|
onMove={handleOnMove}
|
||||||
<SplitViewPanel view={view} key={view.id} setSlots={setSlots}>
|
onResizing={dxy => onResizing(order, dxy)}
|
||||||
{resizeHandleRenderer(view, index)}
|
draggingEntity={draggingEntity}
|
||||||
</SplitViewPanel>
|
>
|
||||||
) : null
|
{renderer(view)}
|
||||||
)}
|
</SplitViewPanel>
|
||||||
</SortableContext>
|
|
||||||
</DndContext>
|
|
||||||
|
|
||||||
{views.map((view, index) => {
|
|
||||||
const slot = slots[view.id]?.current;
|
|
||||||
if (!slot) return null;
|
|
||||||
return createPortal(
|
|
||||||
renderer(view, index),
|
|
||||||
slot,
|
|
||||||
`portalToSplitViewPanel_${view.id}`
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</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 { 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 { isNewTabTrigger } from '@affine/core/utils';
|
||||||
import { useLiveData, useServices } from '@toeverything/infra';
|
import { useLiveData, useServices } from '@toeverything/infra';
|
||||||
import { type To } from 'history';
|
import { type To } from 'history';
|
||||||
import { forwardRef, type MouseEvent } from 'react';
|
import { forwardRef, type MouseEvent } from 'react';
|
||||||
|
|
||||||
import { FeatureFlagService } from '../../feature-flag';
|
import { resolveRouteLinkMeta } from '../../navigation/utils';
|
||||||
import { WorkbenchService } from '../services/workbench';
|
import { WorkbenchService } from '../services/workbench';
|
||||||
|
|
||||||
export type WorkbenchLinkProps = React.PropsWithChildren<
|
export type WorkbenchLinkProps = React.PropsWithChildren<
|
||||||
@@ -15,20 +17,45 @@ export type WorkbenchLinkProps = React.PropsWithChildren<
|
|||||||
} & React.HTMLProps<HTMLAnchorElement>
|
} & 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>(
|
export const WorkbenchLink = forwardRef<HTMLAnchorElement, WorkbenchLinkProps>(
|
||||||
function WorkbenchLink({ to, onClick, replaceHistory, ...other }, ref) {
|
function WorkbenchLink({ to, onClick, replaceHistory, ...other }, ref) {
|
||||||
const { featureFlagService, workbenchService } = useServices({
|
const { workbenchService } = useServices({
|
||||||
FeatureFlagService,
|
|
||||||
WorkbenchService,
|
WorkbenchService,
|
||||||
});
|
});
|
||||||
const enableMultiView = useLiveData(
|
|
||||||
featureFlagService.flags.enable_multi_view.$
|
|
||||||
);
|
|
||||||
const workbench = workbenchService.workbench;
|
const workbench = workbenchService.workbench;
|
||||||
const basename = useLiveData(workbench.basename$);
|
const basename = useLiveData(workbench.basename$);
|
||||||
const link =
|
const stringTo =
|
||||||
basename +
|
typeof to === 'string' ? to : `${to.pathname}${to.search}${to.hash}`;
|
||||||
(typeof to === 'string' ? to : `${to.pathname}${to.search}${to.hash}`);
|
const link = basename + stringTo;
|
||||||
const handleClick = useAsyncCallback(
|
const handleClick = useAsyncCallback(
|
||||||
async (event: React.MouseEvent<HTMLAnchorElement>) => {
|
async (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||||
onClick?.(event);
|
onClick?.(event);
|
||||||
@@ -37,9 +64,7 @@ export const WorkbenchLink = forwardRef<HTMLAnchorElement, WorkbenchLinkProps>(
|
|||||||
}
|
}
|
||||||
const at = (() => {
|
const at = (() => {
|
||||||
if (isNewTabTrigger(event)) {
|
if (isNewTabTrigger(event)) {
|
||||||
return BUILD_CONFIG.isElectron && event.altKey && enableMultiView
|
return BUILD_CONFIG.isElectron && event.altKey ? 'tail' : 'new-tab';
|
||||||
? 'tail'
|
|
||||||
: 'new-tab';
|
|
||||||
}
|
}
|
||||||
return 'active';
|
return 'active';
|
||||||
})();
|
})();
|
||||||
@@ -47,15 +72,32 @@ export const WorkbenchLink = forwardRef<HTMLAnchorElement, WorkbenchLinkProps>(
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
},
|
},
|
||||||
[enableMultiView, onClick, replaceHistory, to, workbench]
|
[onClick, replaceHistory, to, workbench]
|
||||||
);
|
);
|
||||||
|
|
||||||
// eslint suspicious runtime error
|
const { dragRef } = useDraggable<AffineDNDData>(() => {
|
||||||
// eslint-disable-next-line react/no-danger-with-children
|
return {
|
||||||
|
data: {
|
||||||
|
entity: resolveToEntity(to, basename),
|
||||||
|
from: {
|
||||||
|
at: 'workbench:link',
|
||||||
|
to: stringTo,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, [to, basename, stringTo]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
{...other}
|
{...other}
|
||||||
ref={ref}
|
ref={node => {
|
||||||
|
dragRef.current = node;
|
||||||
|
if (typeof ref === 'function') {
|
||||||
|
ref(node);
|
||||||
|
} else if (ref) {
|
||||||
|
ref.current = node;
|
||||||
|
}
|
||||||
|
}}
|
||||||
href={link}
|
href={link}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onAuxClick={handleClick}
|
onAuxClick={handleClick}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ export const workbenchRootContainer = style({
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
overflow: 'hidden',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const workbenchViewContainer = style({
|
export const workbenchViewContainer = style({
|
||||||
|
|||||||
@@ -48,8 +48,8 @@ export const WorkbenchRoot = memo(() => {
|
|||||||
|
|
||||||
useAdapter(workbench, basename);
|
useAdapter(workbench, basename);
|
||||||
|
|
||||||
const panelRenderer = useCallback((view: View, index: number) => {
|
const panelRenderer = useCallback((view: View) => {
|
||||||
return <WorkbenchView key={view.id} view={view} index={index} />;
|
return <WorkbenchView view={view} />;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onMove = useCallback(
|
const onMove = useCallback(
|
||||||
@@ -78,12 +78,12 @@ export const WorkbenchRoot = memo(() => {
|
|||||||
|
|
||||||
WorkbenchRoot.displayName = 'memo(WorkbenchRoot)';
|
WorkbenchRoot.displayName = 'memo(WorkbenchRoot)';
|
||||||
|
|
||||||
const WorkbenchView = ({ view, index }: { view: View; index: number }) => {
|
const WorkbenchView = ({ view }: { view: View }) => {
|
||||||
const workbench = useService(WorkbenchService).workbench;
|
const workbench = useService(WorkbenchService).workbench;
|
||||||
|
|
||||||
const handleOnFocus = useCallback(() => {
|
const handleOnFocus = useCallback(() => {
|
||||||
workbench.active(index);
|
workbench.active(view);
|
||||||
}, [workbench, index]);
|
}, [workbench, view]);
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
@@ -149,7 +149,7 @@ const WorkbenchSidebar = () => {
|
|||||||
<ResizePanel
|
<ResizePanel
|
||||||
floating={floating}
|
floating={floating}
|
||||||
resizeHandlePos="left"
|
resizeHandlePos="left"
|
||||||
resizeHandleOffset={0}
|
resizeHandleOffset={clientBorder && sidebarOpen ? 3 : 0}
|
||||||
width={width}
|
width={width}
|
||||||
resizing={resizing}
|
resizing={resizing}
|
||||||
onResizing={setResizing}
|
onResizing={setResizing}
|
||||||
|
|||||||
@@ -1,28 +1,30 @@
|
|||||||
import type { DNDData } from '@affine/component';
|
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 {
|
export interface AffineDNDData extends DNDData {
|
||||||
draggable: {
|
draggable: {
|
||||||
entity?:
|
entity?: AffineDNDEntity;
|
||||||
| {
|
|
||||||
type: 'doc';
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'folder';
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'collection';
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'tag';
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'custom-property';
|
|
||||||
id: string;
|
|
||||||
};
|
|
||||||
from?:
|
from?:
|
||||||
| {
|
| {
|
||||||
at: 'explorer:organize:folder-node';
|
at: 'explorer:organize:folder-node';
|
||||||
@@ -79,6 +81,19 @@ export interface AffineDNDData extends DNDData {
|
|||||||
at: 'doc-detail:header';
|
at: 'doc-detail:header';
|
||||||
docId: string;
|
docId: string;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
at: 'workbench:view';
|
||||||
|
viewId: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
at: 'workbench:link';
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
at: 'workbench:resize-handle';
|
||||||
|
viewId: string;
|
||||||
|
edge: 'left' | 'right';
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
at: 'blocksuite-editor';
|
at: 'blocksuite-editor';
|
||||||
}
|
}
|
||||||
@@ -114,5 +129,9 @@ export interface AffineDNDData extends DNDData {
|
|||||||
| {
|
| {
|
||||||
at: 'app-header:tabs';
|
at: 'app-header:tabs';
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
at: 'workbench:view';
|
||||||
|
viewId: string;
|
||||||
|
}
|
||||||
| Record<string, unknown>;
|
| Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { test } from '@affine-test/kit/electron';
|
||||||
|
import {
|
||||||
|
expectActiveTab,
|
||||||
|
expectTabTitle,
|
||||||
|
} from '@affine-test/kit/utils/app-tabs';
|
||||||
|
import {
|
||||||
|
clickNewPageButton,
|
||||||
|
createLinkedPage,
|
||||||
|
dragTo,
|
||||||
|
waitForAllPagesLoad,
|
||||||
|
} from '@affine-test/kit/utils/page-logic';
|
||||||
|
import { clickSideBarAllPageButton } from '@affine-test/kit/utils/sidebar';
|
||||||
|
import { expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test('open split view', async ({ page }) => {
|
||||||
|
await clickNewPageButton(page);
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await createLinkedPage(page, 'hi from another page');
|
||||||
|
await page
|
||||||
|
.locator('.affine-reference-title:has-text("hi from another page")')
|
||||||
|
.click({
|
||||||
|
modifiers: ['ControlOrMeta', 'Alt'],
|
||||||
|
});
|
||||||
|
await expect(page.locator('.doc-title-container')).toHaveCount(2);
|
||||||
|
|
||||||
|
// check tab title
|
||||||
|
await expect(page.getByTestId('split-view-label')).toHaveCount(2);
|
||||||
|
await expectTabTitle(page, 0, ['Untitled', 'hi from another page']);
|
||||||
|
|
||||||
|
// the second split view should be active
|
||||||
|
await expectActiveTab(page, 0, 1);
|
||||||
|
|
||||||
|
// by clicking the first split view label, the first split view should be active
|
||||||
|
await page.getByTestId('split-view-label').nth(0).click();
|
||||||
|
await expectActiveTab(page, 0, 0);
|
||||||
|
await expect(page.getByTestId('split-view-indicator').nth(0)).toHaveAttribute(
|
||||||
|
'data-active',
|
||||||
|
'true'
|
||||||
|
);
|
||||||
|
|
||||||
|
const firstDragHandel = page
|
||||||
|
.getByTestId('split-view-panel')
|
||||||
|
.first()
|
||||||
|
.getByTestId('split-view-indicator');
|
||||||
|
|
||||||
|
await dragTo(
|
||||||
|
page,
|
||||||
|
firstDragHandel,
|
||||||
|
page.getByTestId('split-view-panel').last(),
|
||||||
|
'center',
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
await expectTabTitle(page, 0, ['hi from another page', 'Untitled']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('open split view in all docs (operations button)', async ({ page }) => {
|
||||||
|
const testTitle = 'test-page';
|
||||||
|
await clickNewPageButton(page, testTitle);
|
||||||
|
await clickSideBarAllPageButton(page);
|
||||||
|
await waitForAllPagesLoad(page);
|
||||||
|
await page
|
||||||
|
.getByTestId('page-list-item')
|
||||||
|
.filter({
|
||||||
|
hasText: testTitle,
|
||||||
|
})
|
||||||
|
.getByTestId('page-list-operation-button')
|
||||||
|
.click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Open in Split View' }).click();
|
||||||
|
await expect(page.getByTestId('split-view-panel')).toHaveCount(2);
|
||||||
|
const targetPage = page.getByTestId('split-view-panel').last();
|
||||||
|
await expect(targetPage).toHaveAttribute('data-is-active', 'true');
|
||||||
|
await expect(targetPage.locator('.doc-title-container')).toBeVisible();
|
||||||
|
await expect(targetPage.locator('.doc-title-container')).toContainText(
|
||||||
|
testTitle
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('open split view in all docs (drag to resize handle)', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const testTitle = 'test-page';
|
||||||
|
await clickNewPageButton(page, testTitle);
|
||||||
|
await clickSideBarAllPageButton(page);
|
||||||
|
await waitForAllPagesLoad(page);
|
||||||
|
|
||||||
|
// case for AF-2061. toggle selection checkbox
|
||||||
|
await page.getByTestId('page-list-header-selection-checkbox').click();
|
||||||
|
|
||||||
|
const pageItem = page.getByTestId('page-list-item').filter({
|
||||||
|
hasText: testTitle,
|
||||||
|
});
|
||||||
|
|
||||||
|
const leftResizeHandle = page.getByTestId('resize-handle').first();
|
||||||
|
|
||||||
|
await dragTo(page, pageItem, leftResizeHandle, 'center');
|
||||||
|
await expectTabTitle(page, 0, ['test-page', 'All docs']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creating split view by dragging sidebar journals', async ({ page }) => {
|
||||||
|
const journalButton = page.getByTestId('slider-bar-journals-button');
|
||||||
|
const leftResizeHandle = page.getByTestId('resize-handle').first();
|
||||||
|
|
||||||
|
await dragTo(page, journalButton, leftResizeHandle, 'center');
|
||||||
|
await expect(page.getByTestId('split-view-panel')).toHaveCount(2);
|
||||||
|
await expect(
|
||||||
|
page
|
||||||
|
.getByTestId('split-view-panel')
|
||||||
|
.filter({
|
||||||
|
has: page.locator('[data-is-first="true"]'),
|
||||||
|
})
|
||||||
|
.getByTestId('date-today-label')
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
@@ -1,57 +1,17 @@
|
|||||||
import { test } from '@affine-test/kit/electron';
|
import { test } from '@affine-test/kit/electron';
|
||||||
|
import {
|
||||||
|
closeTab,
|
||||||
|
expectActiveTab,
|
||||||
|
expectTabCount,
|
||||||
|
expectTabTitle,
|
||||||
|
} from '@affine-test/kit/utils/app-tabs';
|
||||||
import {
|
import {
|
||||||
clickNewPageButton,
|
clickNewPageButton,
|
||||||
createLinkedPage,
|
createLinkedPage,
|
||||||
dragTo,
|
dragTo,
|
||||||
} from '@affine-test/kit/utils/page-logic';
|
} from '@affine-test/kit/utils/page-logic';
|
||||||
import { clickSideBarAllPageButton } from '@affine-test/kit/utils/sidebar';
|
import { clickSideBarAllPageButton } from '@affine-test/kit/utils/sidebar';
|
||||||
import { expect, type Page } from '@playwright/test';
|
import { expect } from '@playwright/test';
|
||||||
|
|
||||||
async function expectActiveTab(page: Page, index: number, activeViewIndex = 0) {
|
|
||||||
await expect(
|
|
||||||
page
|
|
||||||
.getByTestId('workbench-tab')
|
|
||||||
.nth(index)
|
|
||||||
.getByTestId('split-view-label')
|
|
||||||
.nth(activeViewIndex)
|
|
||||||
).toHaveAttribute('data-active', 'true');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function expectTabTitle(
|
|
||||||
page: Page,
|
|
||||||
index: number,
|
|
||||||
title: string | string[]
|
|
||||||
) {
|
|
||||||
if (typeof title === 'string') {
|
|
||||||
await expect(page.getByTestId('workbench-tab').nth(index)).toContainText(
|
|
||||||
title
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
for (let i = 0; i < title.length; i++) {
|
|
||||||
await expect(
|
|
||||||
page
|
|
||||||
.getByTestId('workbench-tab')
|
|
||||||
.nth(index)
|
|
||||||
.getByTestId('split-view-label')
|
|
||||||
.nth(i)
|
|
||||||
).toContainText(title[i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function expectTabCount(page: Page, count: number) {
|
|
||||||
await expect(page.getByTestId('workbench-tab')).toHaveCount(count);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function closeTab(page: Page, index: number) {
|
|
||||||
await page.getByTestId('workbench-tab').nth(index).hover();
|
|
||||||
|
|
||||||
await page
|
|
||||||
.getByTestId('workbench-tab')
|
|
||||||
.nth(index)
|
|
||||||
.getByTestId('close-tab-button')
|
|
||||||
.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
test('create new tab', async ({ views }) => {
|
test('create new tab', async ({ views }) => {
|
||||||
let page = await views.getActive();
|
let page = await views.getActive();
|
||||||
@@ -148,34 +108,6 @@ test('open new tab via cmd+click page link', async ({ page }) => {
|
|||||||
await expectActiveTab(page, 0);
|
await expectActiveTab(page, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('open split view', async ({ page }) => {
|
|
||||||
await clickNewPageButton(page);
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
await page.keyboard.press('Enter');
|
|
||||||
await createLinkedPage(page, 'hi from another page');
|
|
||||||
await page
|
|
||||||
.locator('.affine-reference-title:has-text("hi from another page")')
|
|
||||||
.click({
|
|
||||||
modifiers: ['ControlOrMeta', 'Alt'],
|
|
||||||
});
|
|
||||||
await expect(page.locator('.doc-title-container')).toHaveCount(2);
|
|
||||||
|
|
||||||
// check tab title
|
|
||||||
await expect(page.getByTestId('split-view-label')).toHaveCount(2);
|
|
||||||
await expectTabTitle(page, 0, ['Untitled', 'hi from another page']);
|
|
||||||
|
|
||||||
// the second split view should be active
|
|
||||||
await expectActiveTab(page, 0, 1);
|
|
||||||
|
|
||||||
// by clicking the first split view label, the first split view should be active
|
|
||||||
await page.getByTestId('split-view-label').nth(0).click();
|
|
||||||
await expectActiveTab(page, 0, 0);
|
|
||||||
await expect(page.getByTestId('split-view-indicator').nth(0)).toHaveAttribute(
|
|
||||||
'data-active',
|
|
||||||
'true'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('drag a page from "All pages" list to tabs header', async ({ page }) => {
|
test('drag a page from "All pages" list to tabs header', async ({ page }) => {
|
||||||
const title = 'this is a new page to drag';
|
const title = 'this is a new page to drag';
|
||||||
await clickNewPageButton(page, title);
|
await clickNewPageButton(page, title);
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
import { expect } from '@playwright/test';
|
||||||
|
|
||||||
|
export async function expectActiveTab(
|
||||||
|
page: Page,
|
||||||
|
index: number,
|
||||||
|
activeViewIndex = 0
|
||||||
|
) {
|
||||||
|
await expect(
|
||||||
|
page
|
||||||
|
.getByTestId('workbench-tab')
|
||||||
|
.nth(index)
|
||||||
|
.getByTestId('split-view-label')
|
||||||
|
.nth(activeViewIndex)
|
||||||
|
).toHaveAttribute('data-active', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectTabTitle(
|
||||||
|
page: Page,
|
||||||
|
index: number,
|
||||||
|
title: string | string[]
|
||||||
|
) {
|
||||||
|
if (typeof title === 'string') {
|
||||||
|
await expect(page.getByTestId('workbench-tab').nth(index)).toContainText(
|
||||||
|
title
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
for (let i = 0; i < title.length; i++) {
|
||||||
|
await expect(
|
||||||
|
page
|
||||||
|
.getByTestId('workbench-tab')
|
||||||
|
.nth(index)
|
||||||
|
.getByTestId('split-view-label')
|
||||||
|
.nth(i)
|
||||||
|
).toContainText(title[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectTabCount(page: Page, count: number) {
|
||||||
|
await expect(page.getByTestId('workbench-tab')).toHaveCount(count);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeTab(page: Page, index: number) {
|
||||||
|
await page.getByTestId('workbench-tab').nth(index).hover();
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByTestId('workbench-tab')
|
||||||
|
.nth(index)
|
||||||
|
.getByTestId('close-tab-button')
|
||||||
|
.click();
|
||||||
|
}
|
||||||
@@ -139,7 +139,8 @@ export const dragTo = async (
|
|||||||
page: Page,
|
page: Page,
|
||||||
locator: Locator,
|
locator: Locator,
|
||||||
target: Locator,
|
target: Locator,
|
||||||
location: DragLocation = 'center'
|
location: DragLocation = 'center',
|
||||||
|
willMoveOnDrag = false
|
||||||
) => {
|
) => {
|
||||||
await locator.hover();
|
await locator.hover();
|
||||||
const locatorElement = await locator.boundingBox();
|
const locatorElement = await locator.boundingBox();
|
||||||
@@ -164,18 +165,20 @@ export const dragTo = async (
|
|||||||
|
|
||||||
await target.hover();
|
await target.hover();
|
||||||
|
|
||||||
const targetElement = await target.boundingBox();
|
if (!willMoveOnDrag) {
|
||||||
if (!targetElement) {
|
const targetElement = await target.boundingBox();
|
||||||
throw new Error('target element not found');
|
if (!targetElement) {
|
||||||
}
|
throw new Error('target element not found');
|
||||||
const targetPosition = toPosition(targetElement, location);
|
|
||||||
await page.mouse.move(
|
|
||||||
targetElement.x + targetPosition.x,
|
|
||||||
targetElement.y + targetPosition.y,
|
|
||||||
{
|
|
||||||
steps: 10,
|
|
||||||
}
|
}
|
||||||
);
|
const targetPosition = toPosition(targetElement, location);
|
||||||
|
await page.mouse.move(
|
||||||
|
targetElement.x + targetPosition.x,
|
||||||
|
targetElement.y + targetPosition.y,
|
||||||
|
{
|
||||||
|
steps: 10,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
await page.waitForTimeout(100);
|
await page.waitForTimeout(100);
|
||||||
await page.mouse.up();
|
await page.mouse.up();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -361,9 +361,6 @@ __metadata:
|
|||||||
"@blocksuite/icons": "npm:2.2.1"
|
"@blocksuite/icons": "npm:2.2.1"
|
||||||
"@capacitor/app": "npm:^6.0.2"
|
"@capacitor/app": "npm:^6.0.2"
|
||||||
"@capacitor/browser": "npm:^6.0.4"
|
"@capacitor/browser": "npm:^6.0.4"
|
||||||
"@dnd-kit/core": "npm:^6.3.1"
|
|
||||||
"@dnd-kit/modifiers": "npm:^9.0.0"
|
|
||||||
"@dnd-kit/sortable": "npm:^10.0.0"
|
|
||||||
"@dotlottie/player-component": "npm:^2.7.12"
|
"@dotlottie/player-component": "npm:^2.7.12"
|
||||||
"@emotion/cache": "npm:^11.14.0"
|
"@emotion/cache": "npm:^11.14.0"
|
||||||
"@emotion/react": "npm:^11.14.0"
|
"@emotion/react": "npm:^11.14.0"
|
||||||
@@ -4534,68 +4531,6 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@dnd-kit/accessibility@npm:^3.1.1":
|
|
||||||
version: 3.1.1
|
|
||||||
resolution: "@dnd-kit/accessibility@npm:3.1.1"
|
|
||||||
dependencies:
|
|
||||||
tslib: "npm:^2.0.0"
|
|
||||||
peerDependencies:
|
|
||||||
react: ">=16.8.0"
|
|
||||||
checksum: 10/961000456a36700a9cd13be51147a818bc100f7dfabb332b80438d02e06f3b556aa0ff46ddf13bdff3b70bc8f9b63dd5a392cc285597ab1f7026e672660c54b6
|
|
||||||
languageName: node
|
|
||||||
linkType: hard
|
|
||||||
|
|
||||||
"@dnd-kit/core@npm:^6.3.1":
|
|
||||||
version: 6.3.1
|
|
||||||
resolution: "@dnd-kit/core@npm:6.3.1"
|
|
||||||
dependencies:
|
|
||||||
"@dnd-kit/accessibility": "npm:^3.1.1"
|
|
||||||
"@dnd-kit/utilities": "npm:^3.2.2"
|
|
||||||
tslib: "npm:^2.0.0"
|
|
||||||
peerDependencies:
|
|
||||||
react: ">=16.8.0"
|
|
||||||
react-dom: ">=16.8.0"
|
|
||||||
checksum: 10/a5ae6fa8404765712aa80e308f58cb79bac9a306c274ec8272c405c2a59dd277d24b966348fe8ca6340bb3f0d75f90b8a021fa781edcf65255114d3cf2bef891
|
|
||||||
languageName: node
|
|
||||||
linkType: hard
|
|
||||||
|
|
||||||
"@dnd-kit/modifiers@npm:^9.0.0":
|
|
||||||
version: 9.0.0
|
|
||||||
resolution: "@dnd-kit/modifiers@npm:9.0.0"
|
|
||||||
dependencies:
|
|
||||||
"@dnd-kit/utilities": "npm:^3.2.2"
|
|
||||||
tslib: "npm:^2.0.0"
|
|
||||||
peerDependencies:
|
|
||||||
"@dnd-kit/core": ^6.3.0
|
|
||||||
react: ">=16.8.0"
|
|
||||||
checksum: 10/2ae238a1b787029e95d92319d7e4a0e2ffba8fceed56c4b58dfee7ed6890df207bf89ce522d4126411051121954222bd8e1444fae321485b594ae518c7c4397d
|
|
||||||
languageName: node
|
|
||||||
linkType: hard
|
|
||||||
|
|
||||||
"@dnd-kit/sortable@npm:^10.0.0":
|
|
||||||
version: 10.0.0
|
|
||||||
resolution: "@dnd-kit/sortable@npm:10.0.0"
|
|
||||||
dependencies:
|
|
||||||
"@dnd-kit/utilities": "npm:^3.2.2"
|
|
||||||
tslib: "npm:^2.0.0"
|
|
||||||
peerDependencies:
|
|
||||||
"@dnd-kit/core": ^6.3.0
|
|
||||||
react: ">=16.8.0"
|
|
||||||
checksum: 10/bc61c25e76905204a53f91294b8116bf106fa27247eebca2c66478450b2051d7177115a384054e7e5639e6c4430083ade63056f79ee45f549da537cf05bc5288
|
|
||||||
languageName: node
|
|
||||||
linkType: hard
|
|
||||||
|
|
||||||
"@dnd-kit/utilities@npm:^3.2.2":
|
|
||||||
version: 3.2.2
|
|
||||||
resolution: "@dnd-kit/utilities@npm:3.2.2"
|
|
||||||
dependencies:
|
|
||||||
tslib: "npm:^2.0.0"
|
|
||||||
peerDependencies:
|
|
||||||
react: ">=16.8.0"
|
|
||||||
checksum: 10/6cfe46a5fcdaced943982e7ae66b08b89235493e106eb5bc833737c25905e13375c6ecc3aa0c357d136cb21dae3966213dba063f19b7a60b1235a29a7b05ff84
|
|
||||||
languageName: node
|
|
||||||
linkType: hard
|
|
||||||
|
|
||||||
"@dotlottie/common@npm:0.7.11":
|
"@dotlottie/common@npm:0.7.11":
|
||||||
version: 0.7.11
|
version: 0.7.11
|
||||||
resolution: "@dotlottie/common@npm:0.7.11"
|
resolution: "@dotlottie/common@npm:0.7.11"
|
||||||
|
|||||||
Reference in New Issue
Block a user