mirror of
https://github.com/aleksey-hoffman/sigma-file-manager.git
synced 2026-10-19 01:31:05 -05:00
429 lines
12 KiB
TypeScript
429 lines
12 KiB
TypeScript
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
// License: GNU GPLv3 or later. See the license file in the project root for more information.
|
|
// Copyright © 2021 - present Aleksey Hoffman. All rights reserved.
|
|
|
|
import {
|
|
onBeforeUnmount,
|
|
type ComputedRef,
|
|
type Ref,
|
|
} from 'vue';
|
|
import type { DirEntry } from '@/types/dir-entry';
|
|
import { UI_CONSTANTS } from '@/constants';
|
|
import {
|
|
buildBoxSelectionBox,
|
|
clientPointToBoxSelectionContentPoint,
|
|
type BoxSelectionContentPoint,
|
|
type BoxSelectionPointerPosition,
|
|
} from '../utils/file-browser-box-selection-coordinates';
|
|
import {
|
|
applyBoxSelectionMode,
|
|
resolveBoxSelectionModeFromPointerEvent,
|
|
shouldClearSelectionOnBoxPointerUp,
|
|
shouldPreserveBaseSelectionForBoxMode,
|
|
type BoxSelectionMode,
|
|
} from '../utils/file-browser-box-selection-policy';
|
|
import {
|
|
collectFileBrowserBoxSelectionEntries,
|
|
type FileBrowserBoxSelectionBox,
|
|
} from '../utils/file-browser-box-selection-hit-test';
|
|
import {
|
|
getElementContentBoxClientRect,
|
|
getElementContentBoxInsets,
|
|
type ElementContentBoxInsets,
|
|
} from '../utils/file-browser-content-box';
|
|
import type { FileBrowserVirtualRow } from '../utils/file-browser-virtual-rows';
|
|
import { clearDocumentTextSelection } from '@/utils/document-selection';
|
|
|
|
const BOX_SELECTION_OVERLAY_CLASS = 'file-browser-box-selection-overlay';
|
|
const FILE_BROWSER_CONTENT_SELECTOR = '.file-browser__content';
|
|
const EDGE_SCROLL_THRESHOLD = 24;
|
|
const EDGE_SCROLL_STEP = 12;
|
|
|
|
function resolveOverlayHostElement(paneElement: HTMLElement): HTMLElement {
|
|
return paneElement.querySelector<HTMLElement>(FILE_BROWSER_CONTENT_SELECTOR) ?? paneElement;
|
|
}
|
|
|
|
function buildHostRelativeOverlayStyle(
|
|
selectionBox: FileBrowserBoxSelectionBox,
|
|
hostRect: DOMRect,
|
|
): {
|
|
transform: string;
|
|
width: string;
|
|
height: string;
|
|
} {
|
|
const left = Math.min(selectionBox.left, selectionBox.right) - hostRect.left;
|
|
const top = Math.min(selectionBox.top, selectionBox.bottom) - hostRect.top;
|
|
const width = Math.abs(selectionBox.right - selectionBox.left);
|
|
const height = Math.abs(selectionBox.bottom - selectionBox.top);
|
|
|
|
return {
|
|
transform: `translate3d(${left}px, ${top}px, 0)`,
|
|
width: `${width}px`,
|
|
height: `${height}px`,
|
|
};
|
|
}
|
|
|
|
function applyEdgeAutoScroll(
|
|
viewport: HTMLElement,
|
|
pointer: BoxSelectionPointerPosition,
|
|
) {
|
|
const viewportRect = viewport.getBoundingClientRect();
|
|
const maxScrollTop = Math.max(0, viewport.scrollHeight - viewport.clientHeight);
|
|
const maxScrollLeft = Math.max(0, viewport.scrollWidth - viewport.clientWidth);
|
|
|
|
if (pointer.clientY < viewportRect.top + EDGE_SCROLL_THRESHOLD) {
|
|
viewport.scrollTop = Math.max(0, viewport.scrollTop - EDGE_SCROLL_STEP);
|
|
}
|
|
else if (pointer.clientY > viewportRect.bottom - EDGE_SCROLL_THRESHOLD) {
|
|
viewport.scrollTop = Math.min(maxScrollTop, viewport.scrollTop + EDGE_SCROLL_STEP);
|
|
}
|
|
|
|
if (pointer.clientX < viewportRect.left + EDGE_SCROLL_THRESHOLD) {
|
|
viewport.scrollLeft = Math.max(0, viewport.scrollLeft - EDGE_SCROLL_STEP);
|
|
}
|
|
else if (pointer.clientX > viewportRect.right - EDGE_SCROLL_THRESHOLD) {
|
|
viewport.scrollLeft = Math.min(maxScrollLeft, viewport.scrollLeft + EDGE_SCROLL_STEP);
|
|
}
|
|
}
|
|
|
|
export function useFileBrowserBoxSelection(options: {
|
|
enabled: ComputedRef<boolean>;
|
|
layout: () => 'list' | 'grid' | undefined;
|
|
paneElementRef: Ref<HTMLElement | null>;
|
|
scrollViewportRef: Ref<HTMLElement | null>;
|
|
entriesContainerRef: Ref<HTMLElement | null>;
|
|
viewportWidth: Ref<number>;
|
|
virtualContentOffset: Ref<number>;
|
|
scrollTop: Ref<number>;
|
|
virtualRows: ComputedRef<FileBrowserVirtualRow[]>;
|
|
increaseFileViewGaps: ComputedRef<boolean>;
|
|
getSelectedEntries: () => DirEntry[];
|
|
applySelection: (entries: DirEntry[]) => void;
|
|
clearSelection: () => void;
|
|
isFileDragActive: ComputedRef<boolean>;
|
|
}) {
|
|
let overlayElement: HTMLDivElement | null = null;
|
|
let animationFrameId: number | null = null;
|
|
let pointerOriginClient: BoxSelectionPointerPosition | null = null;
|
|
let pointerOriginContent: BoxSelectionContentPoint | null = null;
|
|
let latestPointer: BoxSelectionPointerPosition | null = null;
|
|
let scrollViewportElement: HTMLElement | null = null;
|
|
let entriesContainerContentBoxInsets: ElementContentBoxInsets | null = null;
|
|
let selectionMode: BoxSelectionMode = 'replace';
|
|
let baseSelection: DirEntry[] = [];
|
|
let isActive = false;
|
|
let activationThresholdReached = false;
|
|
let previousBodyUserSelect = '';
|
|
|
|
function ensureOverlayElement(): HTMLDivElement | null {
|
|
const paneElement = options.paneElementRef.value;
|
|
|
|
if (!paneElement) {
|
|
return null;
|
|
}
|
|
|
|
const overlayHost = resolveOverlayHostElement(paneElement);
|
|
|
|
if (!overlayElement) {
|
|
overlayElement = document.createElement('div');
|
|
overlayElement.className = BOX_SELECTION_OVERLAY_CLASS;
|
|
overlayElement.style.display = 'none';
|
|
overlayHost.appendChild(overlayElement);
|
|
}
|
|
else if (overlayElement.parentElement !== overlayHost) {
|
|
overlayHost.appendChild(overlayElement);
|
|
}
|
|
|
|
return overlayElement;
|
|
}
|
|
|
|
function hideOverlay() {
|
|
if (!overlayElement) {
|
|
return;
|
|
}
|
|
|
|
overlayElement.style.display = 'none';
|
|
}
|
|
|
|
function updateOverlay(selectionBox: FileBrowserBoxSelectionBox, overlayHostRect: DOMRect) {
|
|
const overlay = ensureOverlayElement();
|
|
|
|
if (!overlay) {
|
|
return;
|
|
}
|
|
|
|
const style = buildHostRelativeOverlayStyle(selectionBox, overlayHostRect);
|
|
overlay.style.display = 'block';
|
|
overlay.style.transform = style.transform;
|
|
overlay.style.width = style.width;
|
|
overlay.style.height = style.height;
|
|
}
|
|
|
|
function applyBoxSelection(
|
|
selectionBox: FileBrowserBoxSelectionBox,
|
|
scrollTop: number,
|
|
viewportRect: DOMRect,
|
|
contentRect: DOMRect,
|
|
) {
|
|
const boxSelectionEntries = collectFileBrowserBoxSelectionEntries({
|
|
rows: options.virtualRows.value,
|
|
selectionBox,
|
|
layout: options.layout(),
|
|
contentRect,
|
|
viewportRect,
|
|
contentWidth: options.viewportWidth.value,
|
|
virtualContentOffset: options.virtualContentOffset.value,
|
|
scrollTop,
|
|
increaseFileViewGaps: options.increaseFileViewGaps.value,
|
|
});
|
|
|
|
options.applySelection(
|
|
applyBoxSelectionMode(baseSelection, boxSelectionEntries, selectionMode),
|
|
);
|
|
}
|
|
|
|
function tick() {
|
|
animationFrameId = null;
|
|
|
|
if (
|
|
!isActive
|
|
|| !pointerOriginContent
|
|
|| !pointerOriginClient
|
|
|| !latestPointer
|
|
|| !entriesContainerContentBoxInsets
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const paneElement = options.paneElementRef.value;
|
|
const viewport = options.scrollViewportRef.value;
|
|
const entriesContainer = options.entriesContainerRef.value;
|
|
|
|
if (!paneElement || !viewport || !entriesContainer) {
|
|
stopBoxSelection();
|
|
return;
|
|
}
|
|
|
|
const paneRect = paneElement.getBoundingClientRect();
|
|
const overlayHostRect = resolveOverlayHostElement(paneElement).getBoundingClientRect();
|
|
const viewportRect = viewport.getBoundingClientRect();
|
|
const contentRect = getElementContentBoxClientRect(
|
|
entriesContainer,
|
|
entriesContainerContentBoxInsets,
|
|
);
|
|
const virtualContentOffset = options.virtualContentOffset.value;
|
|
const deltaX = Math.abs(latestPointer.clientX - pointerOriginClient.clientX);
|
|
const deltaY = Math.abs(latestPointer.clientY - pointerOriginClient.clientY);
|
|
|
|
if (
|
|
!activationThresholdReached
|
|
&& deltaX <= UI_CONSTANTS.DRAG_ACTIVATION_THRESHOLD
|
|
&& deltaY <= UI_CONSTANTS.DRAG_ACTIVATION_THRESHOLD
|
|
) {
|
|
return;
|
|
}
|
|
|
|
activationThresholdReached = true;
|
|
applyEdgeAutoScroll(viewport, latestPointer);
|
|
const scrollTop = viewport.scrollTop;
|
|
const selectionBox = buildBoxSelectionBox(
|
|
pointerOriginContent,
|
|
latestPointer,
|
|
viewportRect,
|
|
contentRect,
|
|
scrollTop,
|
|
viewport.scrollLeft,
|
|
virtualContentOffset,
|
|
paneRect,
|
|
);
|
|
updateOverlay(selectionBox, overlayHostRect);
|
|
applyBoxSelection(selectionBox, scrollTop, viewportRect, contentRect);
|
|
}
|
|
|
|
function scheduleTick() {
|
|
if (animationFrameId !== null) {
|
|
return;
|
|
}
|
|
|
|
animationFrameId = window.requestAnimationFrame(tick);
|
|
}
|
|
|
|
function stopAnimationLoop() {
|
|
if (animationFrameId !== null) {
|
|
window.cancelAnimationFrame(animationFrameId);
|
|
animationFrameId = null;
|
|
}
|
|
}
|
|
|
|
function restoreBodyUserSelect() {
|
|
document.body.style.userSelect = previousBodyUserSelect;
|
|
}
|
|
|
|
function stopBoxSelection() {
|
|
if (!isActive) {
|
|
return;
|
|
}
|
|
|
|
isActive = false;
|
|
stopAnimationLoop();
|
|
hideOverlay();
|
|
restoreBodyUserSelect();
|
|
|
|
window.removeEventListener('pointermove', handlePointerMove);
|
|
window.removeEventListener('pointerup', handlePointerUp);
|
|
window.removeEventListener('pointercancel', handlePointerUp);
|
|
window.removeEventListener('blur', handleWindowBlur);
|
|
detachViewportScrollListener();
|
|
|
|
pointerOriginClient = null;
|
|
pointerOriginContent = null;
|
|
latestPointer = null;
|
|
entriesContainerContentBoxInsets = null;
|
|
selectionMode = 'replace';
|
|
baseSelection = [];
|
|
activationThresholdReached = false;
|
|
}
|
|
|
|
function handlePointerMove(event: PointerEvent) {
|
|
if (!isActive) {
|
|
return;
|
|
}
|
|
|
|
latestPointer = {
|
|
clientX: event.clientX,
|
|
clientY: event.clientY,
|
|
};
|
|
scheduleTick();
|
|
}
|
|
|
|
function handleViewportScroll() {
|
|
if (!isActive) {
|
|
return;
|
|
}
|
|
|
|
scheduleTick();
|
|
}
|
|
|
|
function attachViewportScrollListener() {
|
|
detachViewportScrollListener();
|
|
|
|
const viewport = options.scrollViewportRef.value;
|
|
|
|
if (!viewport) {
|
|
return;
|
|
}
|
|
|
|
scrollViewportElement = viewport;
|
|
scrollViewportElement.addEventListener('scroll', handleViewportScroll, { passive: true });
|
|
}
|
|
|
|
function detachViewportScrollListener() {
|
|
if (!scrollViewportElement) {
|
|
return;
|
|
}
|
|
|
|
scrollViewportElement.removeEventListener('scroll', handleViewportScroll);
|
|
scrollViewportElement = null;
|
|
}
|
|
|
|
function handleWindowBlur() {
|
|
if (!isActive) {
|
|
return;
|
|
}
|
|
|
|
stopBoxSelection();
|
|
}
|
|
|
|
function handlePointerUp(event: PointerEvent) {
|
|
if (!isActive) {
|
|
return;
|
|
}
|
|
|
|
const hadActiveBox = activationThresholdReached;
|
|
|
|
if (shouldClearSelectionOnBoxPointerUp(hadActiveBox, selectionMode, event.button)) {
|
|
options.clearSelection();
|
|
}
|
|
|
|
stopBoxSelection();
|
|
}
|
|
|
|
function handleEntriesContainerPointerDown(event: PointerEvent) {
|
|
if (!options.enabled.value || event.button !== 0 || options.isFileDragActive.value) {
|
|
return;
|
|
}
|
|
|
|
if (!(event.target instanceof Element)) {
|
|
return;
|
|
}
|
|
|
|
if (event.target.closest('[data-entry-path]')) {
|
|
return;
|
|
}
|
|
|
|
if (event.target.closest('.file-browser-loading, .file-browser__empty-state-container, .file-browser-error')) {
|
|
return;
|
|
}
|
|
|
|
const paneElement = options.paneElementRef.value;
|
|
const viewport = options.scrollViewportRef.value;
|
|
const entriesContainer = options.entriesContainerRef.value;
|
|
|
|
if (!paneElement || !viewport || !entriesContainer) {
|
|
return;
|
|
}
|
|
|
|
clearDocumentTextSelection();
|
|
event.preventDefault();
|
|
stopBoxSelection();
|
|
|
|
isActive = true;
|
|
activationThresholdReached = false;
|
|
selectionMode = resolveBoxSelectionModeFromPointerEvent(event);
|
|
baseSelection = shouldPreserveBaseSelectionForBoxMode(selectionMode)
|
|
? [...options.getSelectedEntries()]
|
|
: [];
|
|
|
|
pointerOriginClient = {
|
|
clientX: event.clientX,
|
|
clientY: event.clientY,
|
|
};
|
|
latestPointer = { ...pointerOriginClient };
|
|
entriesContainerContentBoxInsets = getElementContentBoxInsets(entriesContainer);
|
|
pointerOriginContent = clientPointToBoxSelectionContentPoint(
|
|
pointerOriginClient,
|
|
viewport.getBoundingClientRect(),
|
|
getElementContentBoxClientRect(entriesContainer, entriesContainerContentBoxInsets),
|
|
viewport.scrollTop,
|
|
viewport.scrollLeft,
|
|
options.virtualContentOffset.value,
|
|
);
|
|
|
|
previousBodyUserSelect = document.body.style.userSelect;
|
|
document.body.style.userSelect = 'none';
|
|
|
|
window.addEventListener('pointermove', handlePointerMove);
|
|
window.addEventListener('pointerup', handlePointerUp);
|
|
window.addEventListener('pointercancel', handlePointerUp);
|
|
window.addEventListener('blur', handleWindowBlur);
|
|
attachViewportScrollListener();
|
|
|
|
scheduleTick();
|
|
}
|
|
|
|
onBeforeUnmount(() => {
|
|
stopBoxSelection();
|
|
|
|
if (overlayElement?.parentElement) {
|
|
overlayElement.parentElement.removeChild(overlayElement);
|
|
}
|
|
|
|
overlayElement = null;
|
|
});
|
|
|
|
return {
|
|
handleEntriesContainerPointerDown,
|
|
stopBoxSelection,
|
|
};
|
|
}
|