mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 03:20:46 +02:00
Feature/v2/selected pageeditor rework (#4756)
# Description of Changes <!-- Please provide a summary of the changes, including: - What was changed - Why the change was made - Any challenges encountered Closes #(issue_number) --> --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Co-authored-by: James Brunton <[email protected]> Co-authored-by: Copilot <[email protected]>
This commit is contained in:
co-authored by
James Brunton
Copilot
parent
d06391a927
commit
aa20dbb7a6
@@ -68,8 +68,6 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
const isAdmin = config?.isAdmin ?? false;
|
||||
const runningEE = config?.runningEE ?? false;
|
||||
|
||||
console.log('[AppConfigModal] Config:', { isAdmin, runningEE, fullConfig: config });
|
||||
|
||||
// Left navigation structure and icons
|
||||
const configNavSections = useMemo(() =>
|
||||
createConfigNavSections(
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
box-shadow: var(--shadow-md);
|
||||
z-index: 30;
|
||||
white-space: nowrap;
|
||||
pointer-events: auto;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { ActionIcon, Tooltip } from '@mantine/core';
|
||||
import styles from '@app/components/shared/HoverActionMenu.module.css';
|
||||
import { Z_INDEX_HOVER_ACTION_MENU } from '@app/styles/zIndex';
|
||||
|
||||
export interface HoverAction {
|
||||
id: string;
|
||||
@@ -34,7 +35,7 @@ const HoverActionMenu: React.FC<HoverActionMenuProps> = ({
|
||||
return (
|
||||
<div
|
||||
className={`${styles.hoverMenu} ${position === 'outside' ? styles.outside : styles.inside} ${className}`}
|
||||
style={{ opacity: show ? 1 : 0 }}
|
||||
style={{ opacity: show ? 1 : 0, zIndex: Z_INDEX_HOVER_ACTION_MENU }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onMouseUp={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -44,10 +45,10 @@ const HoverActionMenu: React.FC<HoverActionMenuProps> = ({
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
style={{ color: action.color || 'var(--mantine-color-dimmed)' }}
|
||||
disabled={action.disabled}
|
||||
onClick={action.onClick}
|
||||
c={action.color}
|
||||
style={{ color: action.color || 'var(--right-rail-icon)' }}
|
||||
>
|
||||
{action.icon}
|
||||
</ActionIcon>
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import React from 'react';
|
||||
import { Menu, Loader, Group, Text, Checkbox } from '@mantine/core';
|
||||
import { LocalIcon } from '@app/components/shared/LocalIcon';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import FitText from '@app/components/shared/FitText';
|
||||
import { getFileColorWithOpacity } from '@app/components/pageEditor/fileColors';
|
||||
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
|
||||
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
||||
import { useFileItemDragDrop } from '@app/components/shared/pageEditor/useFileItemDragDrop';
|
||||
|
||||
import { FileId } from '@app/types/file';
|
||||
|
||||
// Local interface for PageEditor file display
|
||||
interface PageEditorFile {
|
||||
fileId: FileId;
|
||||
name: string;
|
||||
versionNumber?: number;
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
interface FileMenuItemProps {
|
||||
file: PageEditorFile;
|
||||
index: number;
|
||||
colorIndex: number;
|
||||
onToggleSelection: (fileId: FileId) => void;
|
||||
onReorder: (fromIndex: number, toIndex: number) => void;
|
||||
}
|
||||
|
||||
const FileMenuItem: React.FC<FileMenuItemProps> = ({
|
||||
file,
|
||||
index,
|
||||
colorIndex,
|
||||
onToggleSelection,
|
||||
onReorder,
|
||||
}) => {
|
||||
const {
|
||||
itemRef,
|
||||
isDragging,
|
||||
isDragOver,
|
||||
dropPosition,
|
||||
movedRef,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
} = useFileItemDragDrop({
|
||||
fileId: file.fileId,
|
||||
index,
|
||||
onReorder,
|
||||
});
|
||||
|
||||
const itemName = file?.name || 'Untitled';
|
||||
const fileColorBorder = getFileColorWithOpacity(colorIndex, 1);
|
||||
const fileColorBorderHover = getFileColorWithOpacity(colorIndex, 1.0);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
marginBottom: '0.5rem',
|
||||
}}
|
||||
>
|
||||
{/* Drop indicator line */}
|
||||
{isDragOver && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
...(dropPosition === 'above' ? { top: '-2px' } : { bottom: '-2px' }),
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '4px',
|
||||
backgroundColor: 'rgb(59, 130, 246)',
|
||||
borderRadius: '2px',
|
||||
zIndex: 10,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
ref={itemRef}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (movedRef.current) return; // ignore click after drag
|
||||
onToggleSelection(file.fileId);
|
||||
}}
|
||||
style={{
|
||||
padding: '0.75rem 0.75rem',
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
backgroundColor: file.isSelected ? 'rgba(0, 0, 0, 0.05)' : 'transparent',
|
||||
borderLeft: `6px solid ${fileColorBorder}`,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
transition: 'opacity 0.2s ease-in-out, background-color 0.15s ease',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isDragging) {
|
||||
(e.currentTarget as HTMLDivElement).style.backgroundColor = 'rgba(0, 0, 0, 0.05)';
|
||||
(e.currentTarget as HTMLDivElement).style.borderLeftColor = fileColorBorderHover;
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isDragging) {
|
||||
(e.currentTarget as HTMLDivElement).style.backgroundColor = file.isSelected ? 'rgba(0, 0, 0, 0.05)' : 'transparent';
|
||||
(e.currentTarget as HTMLDivElement).style.borderLeftColor = fileColorBorder;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Group gap="xs" style={{ width: '100%' }}>
|
||||
<div
|
||||
style={{
|
||||
cursor: 'grab',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
color: 'var(--mantine-color-dimmed)',
|
||||
}}
|
||||
>
|
||||
<DragIndicatorIcon fontSize="small" />
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={file.isSelected}
|
||||
onChange={() => onToggleSelection(file.fileId)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
size="sm"
|
||||
/>
|
||||
<div style={{ flex: 1, textAlign: 'left', minWidth: 0 }}>
|
||||
<PrivateContent>
|
||||
<FitText text={itemName} fontSize={14} minimumFontScale={0.7} />
|
||||
</PrivateContent>
|
||||
</div>
|
||||
{file.versionNumber && file.versionNumber > 1 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
v{file.versionNumber}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface PageEditorFileDropdownProps {
|
||||
files: PageEditorFile[];
|
||||
onToggleSelection: (fileId: FileId) => void;
|
||||
onReorder: (fromIndex: number, toIndex: number) => void;
|
||||
switchingTo?: string | null;
|
||||
viewOptionStyle: React.CSSProperties;
|
||||
fileColorMap: Map<string, number>;
|
||||
selectedCount: number;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
export const PageEditorFileDropdown: React.FC<PageEditorFileDropdownProps> = ({
|
||||
files,
|
||||
onToggleSelection,
|
||||
onReorder,
|
||||
switchingTo,
|
||||
viewOptionStyle,
|
||||
fileColorMap,
|
||||
selectedCount,
|
||||
totalCount,
|
||||
}) => {
|
||||
const { openFilesModal } = useFilesModalContext();
|
||||
|
||||
return (
|
||||
<Menu trigger="click" position="bottom" width="40rem">
|
||||
<Menu.Target>
|
||||
<div className="ph-no-capture" style={{...viewOptionStyle, cursor: 'pointer'}}>
|
||||
{switchingTo === "pageEditor" ? (
|
||||
<Loader size="xs" />
|
||||
) : (
|
||||
<LocalIcon icon="dashboard-customize-rounded" width="1.4rem" height="1.4rem" />
|
||||
)}
|
||||
<span className="ph-no-capture">{selectedCount}/{totalCount} files selected</span>
|
||||
<KeyboardArrowDownIcon fontSize="small" />
|
||||
</div>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown className="ph-no-capture" style={{
|
||||
backgroundColor: 'var(--right-rail-bg)',
|
||||
border: '1px solid var(--border-subtle)',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
|
||||
maxHeight: '80vh',
|
||||
overflowY: 'auto'
|
||||
}}>
|
||||
{files.map((file, index) => {
|
||||
const colorIndex = fileColorMap.get(file.fileId as string) ?? 0;
|
||||
|
||||
return (
|
||||
<FileMenuItem
|
||||
key={file.fileId}
|
||||
file={file}
|
||||
index={index}
|
||||
colorIndex={colorIndex}
|
||||
onToggleSelection={onToggleSelection}
|
||||
onReorder={onReorder}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Add File Button */}
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openFilesModal();
|
||||
}}
|
||||
style={{
|
||||
padding: '0.75rem 0.75rem',
|
||||
marginTop: '0.5rem',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: 'transparent',
|
||||
borderTop: '1px solid var(--border-subtle)',
|
||||
transition: 'background-color 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLDivElement).style.backgroundColor = 'rgba(59, 130, 246, 0.25)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLDivElement).style.backgroundColor = 'transparent';
|
||||
}}
|
||||
>
|
||||
<Group gap="xs" style={{ width: '100%' }}>
|
||||
<AddIcon fontSize="small" style={{ color: 'var(--mantine-color-text)' }} />
|
||||
<Text size="sm" fw={500} style={{ color: 'var(--mantine-color-text)' }} className="ph-no-capture">
|
||||
Add File
|
||||
</Text>
|
||||
</Group>
|
||||
</div>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
@@ -177,6 +177,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
|
||||
// Moving into the tooltip → keep open
|
||||
if (isDomNode(related) && tooltipRef.current && tooltipRef.current.contains(related)) {
|
||||
|
||||
(children.props as any)?.onPointerLeave?.(e);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,47 +1,49 @@
|
||||
import React, { useState, useCallback } from "react";
|
||||
import React, { useState, useCallback, useMemo } from "react";
|
||||
import { SegmentedControl, Loader } from "@mantine/core";
|
||||
import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider";
|
||||
import { useRainbowThemeContext } from '@app/components/shared/RainbowThemeProvider';
|
||||
import rainbowStyles from '@app/styles/rainbow.module.css';
|
||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
import EditNoteIcon from "@mui/icons-material/EditNote";
|
||||
import FolderIcon from "@mui/icons-material/Folder";
|
||||
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
||||
import { LocalIcon } from '@app/components/shared/LocalIcon';
|
||||
import { WorkbenchType, isValidWorkbench } from '@app/types/workbench';
|
||||
import { PageEditorFileDropdown } from '@app/components/shared/PageEditorFileDropdown';
|
||||
import type { CustomWorkbenchViewInstance } from '@app/contexts/ToolWorkflowContext';
|
||||
import { FileDropdownMenu } from '@app/components/shared/FileDropdownMenu';
|
||||
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
||||
import { usePageEditorDropdownState, PageEditorDropdownState } from '@app/components/pageEditor/hooks/usePageEditorDropdownState';
|
||||
|
||||
|
||||
const viewOptionStyle: React.CSSProperties = {
|
||||
display: 'inline-flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
whiteSpace: 'nowrap',
|
||||
paddingTop: '0.3rem',
|
||||
gap: '0.5rem',
|
||||
justifyContent: 'center',
|
||||
padding: '2px 1rem',
|
||||
};
|
||||
|
||||
|
||||
// Build view options showing text always
|
||||
// Helper function to create view options for SegmentedControl
|
||||
const createViewOptions = (
|
||||
currentView: WorkbenchType,
|
||||
switchingTo: WorkbenchType | null,
|
||||
activeFiles: Array<{ fileId: string; name: string; versionNumber?: number }>,
|
||||
currentFileIndex: number,
|
||||
onFileSelect?: (index: number) => void,
|
||||
pageEditorState?: PageEditorDropdownState,
|
||||
customViews?: CustomWorkbenchViewInstance[]
|
||||
) => {
|
||||
// Viewer dropdown logic
|
||||
const currentFile = activeFiles[currentFileIndex];
|
||||
const isInViewer = currentView === 'viewer';
|
||||
const fileName = currentFile?.name || '';
|
||||
const displayName = isInViewer && fileName ? fileName : 'Viewer';
|
||||
const viewerDisplayName = isInViewer && fileName ? fileName : 'Viewer';
|
||||
const hasMultipleFiles = activeFiles.length > 1;
|
||||
const showDropdown = isInViewer && hasMultipleFiles;
|
||||
const showViewerDropdown = isInViewer && hasMultipleFiles;
|
||||
|
||||
const viewerOption = {
|
||||
label: showDropdown ? (
|
||||
label: showViewerDropdown ? (
|
||||
<FileDropdownMenu
|
||||
displayName={displayName}
|
||||
displayName={viewerDisplayName}
|
||||
activeFiles={activeFiles}
|
||||
currentFileIndex={currentFileIndex}
|
||||
onFileSelect={onFileSelect}
|
||||
@@ -51,29 +53,38 @@ const createViewOptions = (
|
||||
) : (
|
||||
<div style={viewOptionStyle}>
|
||||
{switchingTo === "viewer" ? (
|
||||
<Loader size="xs" />
|
||||
<Loader size="sm" />
|
||||
) : (
|
||||
<VisibilityIcon fontSize="small" />
|
||||
<VisibilityIcon fontSize="medium" />
|
||||
)}
|
||||
<PrivateContent>{displayName}</PrivateContent>
|
||||
</div>
|
||||
),
|
||||
value: "viewer",
|
||||
};
|
||||
|
||||
// Page Editor dropdown logic
|
||||
const isInPageEditor = currentView === 'pageEditor';
|
||||
const hasPageEditorFiles = pageEditorState && pageEditorState.totalCount > 0;
|
||||
const showPageEditorDropdown = isInPageEditor && hasPageEditorFiles;
|
||||
|
||||
const pageEditorOption = {
|
||||
label: (
|
||||
label: showPageEditorDropdown ? (
|
||||
<PageEditorFileDropdown
|
||||
files={pageEditorState!.files}
|
||||
onToggleSelection={pageEditorState!.onToggleSelection}
|
||||
onReorder={pageEditorState!.onReorder}
|
||||
switchingTo={switchingTo}
|
||||
viewOptionStyle={viewOptionStyle}
|
||||
fileColorMap={pageEditorState!.fileColorMap}
|
||||
selectedCount={pageEditorState!.selectedCount}
|
||||
totalCount={pageEditorState!.totalCount}
|
||||
/>
|
||||
) : (
|
||||
<div style={viewOptionStyle}>
|
||||
{currentView === "pageEditor" ? (
|
||||
<>
|
||||
{switchingTo === "pageEditor" ? <Loader size="xs" /> : <EditNoteIcon fontSize="small" />}
|
||||
<span>Page Editor</span>
|
||||
</>
|
||||
{switchingTo === "pageEditor" ? (
|
||||
<Loader size="sm" />
|
||||
) : (
|
||||
<>
|
||||
{switchingTo === "pageEditor" ? <Loader size="xs" /> : <EditNoteIcon fontSize="small" />}
|
||||
<span>Page Editor</span>
|
||||
</>
|
||||
<LocalIcon icon="dashboard-customize-rounded" width="1.5rem" height="1.5rem" />
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
@@ -83,17 +94,7 @@ const createViewOptions = (
|
||||
const fileEditorOption = {
|
||||
label: (
|
||||
<div style={viewOptionStyle}>
|
||||
{currentView === "fileEditor" ? (
|
||||
<>
|
||||
{switchingTo === "fileEditor" ? <Loader size="xs" /> : <FolderIcon fontSize="small" />}
|
||||
<span>Active Files</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{switchingTo === "fileEditor" ? <Loader size="xs" /> : <FolderIcon fontSize="small" />}
|
||||
<span>Active Files</span>
|
||||
</>
|
||||
)}
|
||||
{switchingTo === "fileEditor" ? <Loader size="sm" /> : <FolderIcon fontSize="medium" />}
|
||||
</div>
|
||||
),
|
||||
value: "fileEditor",
|
||||
@@ -111,9 +112,9 @@ const createViewOptions = (
|
||||
label: (
|
||||
<div style={viewOptionStyle as React.CSSProperties}>
|
||||
{switchingTo === view.workbenchId ? (
|
||||
<Loader size="xs" />
|
||||
<Loader size="sm" />
|
||||
) : (
|
||||
view.icon || <PictureAsPdfIcon fontSize="small" />
|
||||
view.icon || <PictureAsPdfIcon fontSize="medium" />
|
||||
)}
|
||||
<span>{view.label}</span>
|
||||
</div>
|
||||
@@ -144,6 +145,8 @@ const TopControls = ({
|
||||
const { isRainbowMode } = useRainbowThemeContext();
|
||||
const [switchingTo, setSwitchingTo] = useState<WorkbenchType | null>(null);
|
||||
|
||||
const pageEditorState = usePageEditorDropdownState();
|
||||
|
||||
const handleViewChange = useCallback((view: string) => {
|
||||
if (!isValidWorkbench(view)) {
|
||||
return;
|
||||
@@ -166,13 +169,25 @@ const TopControls = ({
|
||||
});
|
||||
}, [setCurrentView]);
|
||||
|
||||
// Memoize view options to prevent SegmentedControl re-renders
|
||||
const viewOptions = useMemo(() => createViewOptions(
|
||||
currentView,
|
||||
switchingTo,
|
||||
activeFiles,
|
||||
currentFileIndex,
|
||||
onFileSelect,
|
||||
pageEditorState,
|
||||
customViews
|
||||
), [currentView, switchingTo, activeFiles, currentFileIndex, onFileSelect, pageEditorState, customViews]);
|
||||
|
||||
return (
|
||||
<div className="absolute left-0 w-full top-0 z-[100] pointer-events-none">
|
||||
<div className="flex justify-center mt-[0.5rem]" style={{ pointerEvents: 'auto' }}>
|
||||
<div className="flex justify-center">
|
||||
|
||||
<SegmentedControl
|
||||
data-tour="view-switcher"
|
||||
data={createViewOptions(currentView, switchingTo, activeFiles, currentFileIndex, onFileSelect, customViews)}
|
||||
data={viewOptions}
|
||||
|
||||
value={currentView}
|
||||
onChange={handleViewChange}
|
||||
color="blue"
|
||||
@@ -185,18 +200,32 @@ const TopControls = ({
|
||||
}}
|
||||
styles={{
|
||||
root: {
|
||||
borderRadius: 9999,
|
||||
maxHeight: '2.6rem',
|
||||
borderRadius: '0 0 16px 16px',
|
||||
height: '1.8rem',
|
||||
backgroundColor: 'var(--bg-toolbar)',
|
||||
border: '1px solid var(--border-default)',
|
||||
borderTop: 'none',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
outline: '1px solid rgba(0, 0, 0, 0.1)',
|
||||
outlineOffset: '-1px',
|
||||
padding: '0 0',
|
||||
gap: '0',
|
||||
},
|
||||
control: {
|
||||
borderRadius: 9999,
|
||||
borderRadius: '0 0 16px 16px',
|
||||
padding: '0',
|
||||
border: 'none',
|
||||
},
|
||||
indicator: {
|
||||
borderRadius: 9999,
|
||||
maxHeight: '2rem',
|
||||
borderRadius: '0 0 16px 16px',
|
||||
height: '100%',
|
||||
top: '0rem',
|
||||
margin: '0',
|
||||
border: 'none',
|
||||
},
|
||||
label: {
|
||||
paddingTop: '0rem',
|
||||
paddingTop: '0',
|
||||
paddingBottom: '0',
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useRef, useEffect, useState } from 'react';
|
||||
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||||
|
||||
import { FileId } from '@app/types/file';
|
||||
|
||||
interface UseFileItemDragDropParams {
|
||||
fileId: FileId;
|
||||
index: number;
|
||||
onReorder: (fromIndex: number, toIndex: number) => void;
|
||||
}
|
||||
|
||||
interface UseFileItemDragDropReturn {
|
||||
itemRef: React.RefObject<HTMLDivElement | null>;
|
||||
isDragging: boolean;
|
||||
isDragOver: boolean;
|
||||
dropPosition: 'above' | 'below';
|
||||
movedRef: React.MutableRefObject<boolean>;
|
||||
startRef: React.MutableRefObject<{ x: number; y: number } | null>;
|
||||
onPointerDown: (e: React.PointerEvent) => void;
|
||||
onPointerMove: (e: React.PointerEvent) => void;
|
||||
onPointerUp: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to handle drag and drop functionality for file items in a list.
|
||||
* Manages drag state, drop zones, and reordering logic using Pragmatic Drag and Drop.
|
||||
*/
|
||||
export const useFileItemDragDrop = ({
|
||||
fileId,
|
||||
index,
|
||||
onReorder,
|
||||
}: UseFileItemDragDropParams): UseFileItemDragDropReturn => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [dropPosition, setDropPosition] = useState<'above' | 'below'>('below');
|
||||
const itemRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Keep latest values without re-registering DnD
|
||||
const indexRef = useRef(index);
|
||||
const fileIdRef = useRef(fileId);
|
||||
const dropPositionRef = useRef<'above' | 'below'>('below');
|
||||
const onReorderRef = useRef(onReorder);
|
||||
|
||||
useEffect(() => { indexRef.current = index; }, [index]);
|
||||
useEffect(() => { fileIdRef.current = fileId; }, [fileId]);
|
||||
useEffect(() => { dropPositionRef.current = dropPosition; }, [dropPosition]);
|
||||
useEffect(() => { onReorderRef.current = onReorder; }, [onReorder]);
|
||||
|
||||
// Gesture guard for row click vs drag
|
||||
const movedRef = useRef(false);
|
||||
const startRef = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
const onPointerDown = (e: React.PointerEvent) => {
|
||||
startRef.current = { x: e.clientX, y: e.clientY };
|
||||
movedRef.current = false;
|
||||
};
|
||||
|
||||
const onPointerMove = (e: React.PointerEvent) => {
|
||||
if (!startRef.current) return;
|
||||
const dx = e.clientX - startRef.current.x;
|
||||
const dy = e.clientY - startRef.current.y;
|
||||
if (dx * dx + dy * dy > 25) movedRef.current = true; // ~5px threshold
|
||||
};
|
||||
|
||||
const onPointerUp = () => {
|
||||
startRef.current = null;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const element = itemRef.current;
|
||||
if (!element) return;
|
||||
|
||||
const dragCleanup = draggable({
|
||||
element,
|
||||
getInitialData: () => ({
|
||||
type: 'file-item',
|
||||
fileId: fileIdRef.current,
|
||||
fromIndex: indexRef.current,
|
||||
}),
|
||||
onDragStart: () => setIsDragging((p) => (p ? p : true)),
|
||||
onDrop: () => setIsDragging((p) => (p ? false : p)),
|
||||
canDrag: () => true,
|
||||
});
|
||||
|
||||
const dropCleanup = dropTargetForElements({
|
||||
element,
|
||||
getData: () => ({
|
||||
type: 'file-item',
|
||||
fileId: fileIdRef.current,
|
||||
toIndex: indexRef.current,
|
||||
}),
|
||||
onDragEnter: () => setIsDragOver((p) => (p ? p : true)),
|
||||
onDragLeave: () => {
|
||||
setIsDragOver((p) => (p ? false : p));
|
||||
setDropPosition('below');
|
||||
},
|
||||
onDrag: ({ source }) => {
|
||||
// Determine drop position based on cursor location
|
||||
const element = itemRef.current;
|
||||
if (!element) return;
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
const clientY = (source as any).element?.getBoundingClientRect().top || 0;
|
||||
const midpoint = rect.top + rect.height / 2;
|
||||
|
||||
setDropPosition(clientY < midpoint ? 'below' : 'above');
|
||||
},
|
||||
onDrop: ({ source }) => {
|
||||
setIsDragOver(false);
|
||||
const dropPos = dropPositionRef.current;
|
||||
setDropPosition('below');
|
||||
const sourceData = source.data as any;
|
||||
if (sourceData?.type === 'file-item') {
|
||||
const fromIndex = sourceData.fromIndex as number;
|
||||
let toIndex = indexRef.current;
|
||||
|
||||
// Adjust toIndex based on drop position
|
||||
if (dropPos === 'below' && fromIndex < toIndex) {
|
||||
// Dragging down, drop after target - no adjustment needed
|
||||
} else if (dropPos === 'above' && fromIndex > toIndex) {
|
||||
// Dragging up, drop before target - no adjustment needed
|
||||
} else if (dropPos === 'below' && fromIndex > toIndex) {
|
||||
// Dragging up but want below target
|
||||
toIndex = toIndex + 1;
|
||||
} else if (dropPos === 'above' && fromIndex < toIndex) {
|
||||
// Dragging down but want above target
|
||||
toIndex = toIndex - 1;
|
||||
}
|
||||
|
||||
if (fromIndex !== toIndex) {
|
||||
onReorderRef.current(fromIndex, toIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
try { dragCleanup(); } catch { /* cleanup */ }
|
||||
try { dropCleanup(); } catch { /* cleanup */ }
|
||||
};
|
||||
}, []); // Stable - no dependencies
|
||||
|
||||
return {
|
||||
itemRef,
|
||||
isDragging,
|
||||
isDragOver,
|
||||
dropPosition,
|
||||
movedRef,
|
||||
startRef,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user