diff --git a/PiPedalCommon/src/json.cpp b/PiPedalCommon/src/json.cpp index 707f543..67460b6 100644 --- a/PiPedalCommon/src/json.cpp +++ b/PiPedalCommon/src/json.cpp @@ -122,11 +122,11 @@ void json_writer::write(string_view v,bool enforceValidUtf8Encoding) } } } - if ((uc >= UTF16_SURROGATE_1_BASE && uc <= UTF16_SURROGATE_1_BASE + UTF16_SURROGATE_MASK) || (uc >= UTF16_SURROGATE_2_BASE && uc <= UTF16_SURROGATE_2_BASE + UTF16_SURROGATE_MASK)) - { - // MUST not encode UTF16 surrogates in UTF8. - throw_encoding_error(); - } + // if ((uc >= UTF16_SURROGATE_1_BASE && uc <= UTF16_SURROGATE_1_BASE + UTF16_SURROGATE_MASK) || (uc >= UTF16_SURROGATE_2_BASE && uc <= UTF16_SURROGATE_2_BASE + UTF16_SURROGATE_MASK)) + // { + // // MUST not encode UTF16 surrogates in UTF8. + // throw_encoding_error(); + // } if (uc == '"' || uc == '\\') { diff --git a/vite/src/pipedal/DraggableButtonBase.tsx b/vite/src/pipedal/DraggableButtonBase.tsx index e12d066..0c967ad 100644 --- a/vite/src/pipedal/DraggableButtonBase.tsx +++ b/vite/src/pipedal/DraggableButtonBase.tsx @@ -26,7 +26,7 @@ import ButtonBase, { ButtonBaseProps } from "@mui/material/ButtonBase"; export interface DraggableButtonBaseProps extends ButtonBaseProps { onClick?: (e: React.MouseEvent) => void; - onLongPressStart?: (currentTarget: HTMLButtonElement, e: React.PointerEvent) => void; + onLongPressStart?: (currentTarget: HTMLButtonElement, e: React.PointerEvent) => boolean; onLongPressMove?: (e: React.PointerEvent) => void; onLongPressEnd?: (e: React.PointerEvent) => void; longPressDelay? : number; @@ -69,9 +69,34 @@ export default function DraggableButtonBase(props: DraggableButtonBaseProps) { let [hTimeout, setHTimeout] = React.useState(null); let [pointerId, setPointerId] = React.useState(null); let [longPressed, setLongPressed] = React.useState(false); - let [suppressClick, setSuppressClick] = React.useState(false); + let [clickSuppressed, setClickSuppressed] = React.useState(false); let [pointerDownPoint, setPointerDownPoint] = React.useState({x: 0, y: 0}); + + function handleSuppressClick(e: MouseEvent) { + e.stopPropagation(); + e.preventDefault(); + setSuppressClick(false); + } + + function setSuppressClick(value: boolean) { + + if (value !== clickSuppressed) { + setClickSuppressed(value); + if (value) { + window.addEventListener( + "click", + handleSuppressClick, + { + once: true, + capture: true + }); + } else { + window.removeEventListener("click", handleSuppressClick); + } + } + } + function cancelLongPress() { if (hTimeout !== null) { window.clearTimeout(hTimeout); @@ -82,8 +107,10 @@ export default function DraggableButtonBase(props: DraggableButtonBaseProps) { setSuppressClick(false); } + useEffect(() => { return () => { + setSuppressClick(false); cancelLongPress(); }; }, []); @@ -106,10 +133,15 @@ export default function DraggableButtonBase(props: DraggableButtonBaseProps) { if (longPressDelay === undefined || longPressDelay >= 0) { setHTimeout(window.setTimeout(() => { + setHTimeout(null); if (props.onLongPressStart) { - props.onLongPressStart(currentTarget,e); + if (!props.onLongPressStart(currentTarget,e)) + { + return; + } } setLongPressed(true); + setSuppressClick(true); }, longPressDelay??1250)); } }} @@ -147,7 +179,7 @@ export default function DraggableButtonBase(props: DraggableButtonBaseProps) { } }} onClick={(e) => { - if (suppressClick) { + if (clickSuppressed) { e.stopPropagation(); e.preventDefault(); setSuppressClick(false); diff --git a/vite/src/pipedal/FilePropertyDialog.tsx b/vite/src/pipedal/FilePropertyDialog.tsx index 482cd1b..dbc823b 100644 --- a/vite/src/pipedal/FilePropertyDialog.tsx +++ b/vite/src/pipedal/FilePropertyDialog.tsx @@ -62,7 +62,7 @@ import UploadFileDialog from './UploadFileDialog'; import OkCancelDialog from './OkCancelDialog'; import HomeIcon from '@mui/icons-material/Home'; import FilePropertyDirectorySelectDialog from './FilePropertyDirectorySelectDialog'; -import { getAlbumArtUri, getTrackTitle } from './AudioFileMetadata'; +import { getAlbumArtUri, getTrackTitle } from './AudioFileMetadata'; interface Point { x: number; @@ -156,11 +156,15 @@ export interface FilePropertyDialogState { newFilePath: string; } | null; menuAnchorEl: null | HTMLElement; + menuMultiselectAnchorEl: null | HTMLElement; newFolderDialogOpen: boolean; renameDialogOpen: boolean; moveDialogOpen: boolean; copyDialogOpen: boolean; initialSelection: string; + multiSelect: boolean, + selectedFiles: string[], + }; class DragState { @@ -220,11 +224,14 @@ export default withStyles( openConfirmDeleteDialog: false, confirmCopyDialogState: null, menuAnchorEl: null, + menuMultiselectAnchorEl: null, newFolderDialogOpen: false, renameDialogOpen: false, moveDialogOpen: false, copyDialogOpen: false, - initialSelection: this.props.selectedFile + initialSelection: this.props.selectedFile, + multiSelect: false, + selectedFiles: [] }; this.requestScroll = true; } @@ -325,58 +332,86 @@ export default withStyles( dragToPosition: number = -1; maxPosition: number = 0; - handleLongPressStart(currentTarget: HTMLButtonElement, e: React.PointerEvent) { - if (!this.isTracksDirectory()) { - return; - } - this.dragFromPosition = parseInt(currentTarget.getAttribute("data-position") as string, 10); - this.dragToPosition = this.dragToPosition; - let element = currentTarget; - element.style.position = "relative"; - element.style.zIndex = "1000"; - element.style.top = "5px"; - element.style.left = "5px"; - element.style.background = isDarkMode() ? "#555" : "#EEF" // xxx: dark mode. - if (this.lastDivRef) { - this.longPressStartPoint = screenToClient(this.lastDivRef, { x: e.screenX, y: e.screenY }); + handleMultisSelectClose() { + this.setState({ + multiSelect: false, selectedFiles: [], menuMultiselectAnchorEl: null + }); + } + + + handleLongPressStart( + currentTarget: HTMLButtonElement, + e: React.PointerEvent, + fileEntry: FileEntry): boolean { + if (this.state.reordering) { + + if (!this.isTracksDirectory()) { + return false; + } + this.dragFromPosition = parseInt(currentTarget.getAttribute("data-position") as string, 10); + this.dragToPosition = this.dragToPosition; + let element = currentTarget; + element.style.position = "relative"; + element.style.zIndex = "1000"; + element.style.top = "5px"; + element.style.left = "5px"; + element.style.background = isDarkMode() ? "#555" : "#EEF" // xxx: dark mode. + if (this.lastDivRef) { + this.longPressStartPoint = screenToClient(this.lastDivRef, { x: e.screenX, y: e.screenY }); + } + return true; + } else if (!this.state.multiSelect) { + if (fileEntry.isProtected) { + return false; + } + this.setState( + { + multiSelect: true, + selectedFiles: [fileEntry.pathname], + } + ); + return true; } + return false; } handleLongPressMove(e: React.PointerEvent) { - if (!this.isTracksDirectory()) { - return; - } - if (this.lastDivRef) { - let element = e.target as HTMLButtonElement; - let point = screenToClient(this.lastDivRef, { x: e.screenX, y: e.screenY }); - if (this.longPressStartPoint) { - let dy = point.y - this.longPressStartPoint.y; - element.style.left = (5) + "px"; - element.style.top = (5 + dy) + "px"; - let height = element.clientHeight; - let positionChange_: number = 0; - if (dy > 0) { - positionChange_ = Math.floor((dy + height / 2) / height); - } else { - positionChange_ = Math.ceil((dy - height / 2) / height) - } - let toPosition = this.dragFromPosition + positionChange_; - if (toPosition < 0) { - toPosition = 0; - } - if (toPosition > this.maxPosition) { - toPosition = this.maxPosition; - } - if (toPosition !== this.dragToPosition) { - this.dragToPosition = toPosition; - this.setState({ - dragState: { - activeItem: element.getAttribute("data-pathname") || "", - height: height, - from: this.dragFromPosition, - to: toPosition - } - }); + if (this.state.reordering) { + if (!this.isTracksDirectory()) { + return; + } + if (this.lastDivRef) { + let element = e.target as HTMLButtonElement; + let point = screenToClient(this.lastDivRef, { x: e.screenX, y: e.screenY }); + if (this.longPressStartPoint) { + let dy = point.y - this.longPressStartPoint.y; + element.style.left = (5) + "px"; + element.style.top = (5 + dy) + "px"; + let height = element.clientHeight; + let positionChange_: number = 0; + if (dy > 0) { + positionChange_ = Math.floor((dy + height / 2) / height); + } else { + positionChange_ = Math.ceil((dy - height / 2) / height) + } + let toPosition = this.dragFromPosition + positionChange_; + if (toPosition < 0) { + toPosition = 0; + } + if (toPosition > this.maxPosition) { + toPosition = this.maxPosition; + } + if (toPosition !== this.dragToPosition) { + this.dragToPosition = toPosition; + this.setState({ + dragState: { + activeItem: element.getAttribute("data-pathname") || "", + height: height, + from: this.dragFromPosition, + to: toPosition + } + }); + } } } } @@ -427,27 +462,40 @@ export default withStyles( this.model.moveAudioFile( this.state.currentDirectory, from, to); } - handleLongPressEnd(e: React.PointerEvent) { - if (!this.isTracksDirectory()) { - return; + handleLongPressEnd(e: React.PointerEvent, fileEntry: FileEntry) { + if (this.state.reordering) { + if (!this.isTracksDirectory()) { + return; + } + let dragState = this.state.dragState; + if (dragState && dragState.to != dragState.from) { + this.handleReorderFiles(dragState.from, dragState.to); + + } + // prevent onClick from firing. + e.preventDefault(); + e.stopPropagation(); + + let element = e.currentTarget as HTMLButtonElement; + + element.style.position = ""; + element.style.zIndex = ""; + element.style.top = ""; + element.style.left = ""; + element.style.background = ""; // xxx: dark mode. + this.setState({ dragState: null }); + } else if (!this.state.multiSelect) { + let selectedFile = fileEntry.pathname; + if (selectedFile === "") { + return; + } + this.setState({ + multiSelect: true, + selectedFiles: [selectedFile], + }); + e.stopPropagation(); + e.preventDefault(); } - let dragState = this.state.dragState; - if (dragState && dragState.to != dragState.from) { - this.handleReorderFiles(dragState.from, dragState.to); - - } - // prevent onClick from firing. - e.preventDefault(); - e.stopPropagation(); - - let element = e.currentTarget as HTMLButtonElement; - - element.style.position = ""; - element.style.zIndex = ""; - element.style.top = ""; - element.style.left = ""; - element.style.background = ""; // xxx: dark mode. - this.setState({ dragState: null }); } onMeasureRef(div: HTMLDivElement | null) { this.lastDivRef = div; @@ -544,10 +592,57 @@ export default withStyles( return undefined; } - onSelectValue(fileEntry: FileEntry) { + + handleFileClick(event: React.MouseEvent, fileEntry: FileEntry) { if (this.state.reordering) { return; } + if (event.ctrlKey || this.state.multiSelect) { + if (fileEntry.isProtected) { + // cannot be multiselected. + return; + } + if (this.state.multiSelect) { + let selectedFiles = this.state.selectedFiles.slice(); + let ix = selectedFiles.indexOf(fileEntry.pathname); + if (ix === -1) { + selectedFiles.push(fileEntry.pathname); + } else { + selectedFiles.splice(ix, 1); + } + if (selectedFiles.length === 0) { + this.setState({ + multiSelect: false, + selectedFiles: [] + }); + } else { + this.setState({ + multiSelect: true, + selectedFiles: selectedFiles + }); + } + } else { + let selectedFiles = [fileEntry.pathname]; + if (this.state.hasSelection) { + if (this.state.selectedFile !== "") { + selectedFiles.push(this.state.selectedFile); + } + } + this.setState({ + multiSelect: true, + selectedFiles: selectedFiles + }); + } + event.stopPropagation(); + event.preventDefault(); + return; + } + if (event.shiftKey) { + event.stopPropagation(); + event.preventDefault(); + return; + + } this.requestScroll = true; if (!fileEntry.isDirectory) { if (!this.isFolderArtwork(fileEntry.pathname)) { @@ -576,8 +671,18 @@ export default withStyles( handleMenuClose() { this.setState({ menuAnchorEl: null }); } + handleMenuMultiSelectOpen(event: React.MouseEvent) { + this.setState({ menuMultiselectAnchorEl: event.currentTarget }); + + } + handleMenuMultiSelectClose() { + this.setState({ menuMultiselectAnchorEl: null }); + } + canDeleteEntry(fileEntry: FileEntry): boolean { + return fileEntry.isProtected; + } handleDelete() { - if (this.state.selectedFileProtected) { + if (this.state.selectedFileProtected && !this.state.multiSelect) { return; } this.setState({ openConfirmDeleteDialog: true }); @@ -589,66 +694,93 @@ export default withStyles( let file = this.state.selectedFile; this.model.downloadAudioFile(file); } - handleConfirmDelete() { - if (this.state.selectedFileProtected) { + async handleConfirmDelete() { + this.setState({ openConfirmDeleteDialog: false }); + let files: string[] = []; + if (this.state.multiSelect) { + files = this.state.selectedFiles; + } else { + if (this.state.selectedFileProtected) { + return; + } + files = [this.state.selectedFile]; + } + let selectedFile = (!this.state.multiSelect && this.state.hasSelection) ? this.state.selectedFile : undefined; + let newSelectedFiles = this.state.selectedFiles.slice(); + let multiSelect = this.state.multiSelect; + let resultFiles = this.state.fileResult.files; + + try { + for (let file of files) { + if (!multiSelect) { + if (selectedFile) { + let position = -1; + for (let i = 0; i < resultFiles.length; ++i) { + let file = resultFiles[i]; + if (file.pathname === selectedFile) { + position = i; + break; + } + } + if (position >= 0 && position < resultFiles.length - 1) { + selectedFile = this.state.fileResult.files[position + 1].pathname; + } else if (position === this.state.fileResult.files.length - 1) { + if (position !== 0) { + selectedFile = this.state.fileResult.files[position - 1].pathname; + } else { + selectedFile = undefined; + } + } else { + selectedFile = undefined; + } + } + } else { + let ix = newSelectedFiles.indexOf(file); + if (ix >= 0) { + newSelectedFiles.splice(ix, 1); + } + } + await this.model.deleteUserFile(file); + } + } catch (e: any) { + this.model.showAlert(e); + } + + // update state for what we've done. + if (!this.mounted) { return; } - this.setState({ openConfirmDeleteDialog: false }); - if (this.state.hasSelection) { - let selectedFile = this.state.selectedFile; - let position = -1; - for (let i = 0; i < this.state.fileResult.files.length; ++i) { - let file = this.state.fileResult.files[i]; - if (file.pathname === selectedFile) { - position = i; - } + if (multiSelect) { + if (newSelectedFiles.length === 0) { + this.setState({ + multiSelect: false, + selectedFiles: [], + }); + } else { + this.setState({ + multiSelect: true, + selectedFiles: newSelectedFiles, + }); } - let newSelection: FileEntry | undefined = undefined; - if (position >= 0 && position < this.state.fileResult.files.length - 1) { - newSelection = this.state.fileResult.files[position + 1]; - } else if (position === this.state.fileResult.files.length - 1) { - if (position !== 0) { - newSelection = this.state.fileResult.files[position - 1]; - } - + } else { + if (selectedFile) { + this.setState({ + selectedFile: selectedFile + }); } - - this.model.deleteUserFile(this.state.selectedFile) - .then( - () => { - if (newSelection) { - this.setState({ - selectedFile: newSelection.pathname, - selectedFileIsDirectory: newSelection.isDirectory, - selectedFileProtected: newSelection.isProtected, - hasSelection: true, - hasFileSelection: !newSelection.isDirectory && !newSelection.isProtected - }); - } else { - this.setState({ - selectedFile: "", - selectedFileIsDirectory: false, - selectedFileProtected: true, - hasSelection: false, - hasFileSelection: false - }); - - } - this.requestFiles(this.state.navDirectory); - } - ).catch( - (e: any) => { - this.model.showAlert(e.toString()); - } - ); } + this.requestFiles(this.state.navDirectory); } - navigate(relativeDirectory: string) { this.requestFiles(relativeDirectory); this.setState({ navDirectory: relativeDirectory }); } + handleBreadcrumbNavigate(relativeDirectory: string) { + if (!this.state.reordering && !this.state.multiSelect) { + this.navigate(relativeDirectory); + } + } getCompactTrackTitle(fileEntry: FileEntry): string { let metadata = fileEntry.metadata; let title = getTrackTitle(fileEntry.pathname, metadata); @@ -679,7 +811,7 @@ export default withStyles( let breadcrumbs: React.ReactElement[] = [( + { this.handleMenuMultiSelectOpen(ev); }} + disabled={protectedDirectory} + + > + + + { this.handleMenuMultiSelectClose(); }} + > + {canMove && ( { this.handleMenuMultiSelectClose(); this.handleMove(); }}>Move)} + {canMove && ( { this.handleMenuMultiSelectClose(); this.handleCopy(); }}>Copy)} + + + + )} + {!(this.state.reordering || this.state.multiSelect) && ( <> { this.handleMenuClose(); this.onNewFolder(); }}>New folder {(needsDivider) && ()} - {canMove && ( { this.handleMenuClose(); this.onMove(); }}>Move)} - {canMove && ( { this.handleMenuClose(); this.onCopy(); }}>Copy)} + {canMove && ( { this.handleMenuClose(); this.handleMove(); }}>Move)} + {canMove && ( { this.handleMenuClose(); this.handleCopy(); }}>Copy)} {canRename && this.hasSelectedFileOrFolder() && !protectedItem && ( { this.handleMenuClose(); this.onRename(); }}>Rename)} @@ -943,12 +1135,12 @@ export default withStyles( { this.handleMenuClose(); this.onReorder(); }}>Reorder)} -
- {this.renderBreadcrumbs()} -
)} +
+ {this.renderBreadcrumbs()} +
this.onSelectValue(value)} + onClick={(e) => this.handleFileClick(e, value)} onDoubleClick={() => { this.onDoubleClickValue(value.pathname); }} onLongPressEnd={(e) => { - this.handleLongPressEnd(e); + this.handleLongPressEnd(e, value); } } onLongPressStart={(currentTarget, e) => { - this.handleLongPressStart(currentTarget, e); + return this.handleLongPressStart(currentTarget, e, value); } } onLongPressMove={(e) => { @@ -1059,20 +1258,20 @@ export default withStyles( display: "flex", flexFlow: "row nowrap", textOverflow: "ellipsis", justifyContent: "start", alignItems: "center", width: "100%", height: "100%" }}> - { - this.isFolderArtwork(value.pathname) ? ( - { e.preventDefault(); }} - src={this.getTrackThumbnail(value)} - style={{ width: 24, height: 24, margin: 20, borderRadius: 4 }} /> - ):( - { e.preventDefault(); }} - src={this.getTrackThumbnail(value)} - style={{ width: 48, height: 48, margin: 8, borderRadius: 4 }} /> + { + this.isFolderArtwork(value.pathname) ? ( + { e.preventDefault(); }} + src={this.getTrackThumbnail(value)} + style={{ width: 24, height: 24, margin: 20, borderRadius: 4 }} /> + ) : ( + { e.preventDefault(); }} + src={this.getTrackThumbnail(value)} + style={{ width: 48, height: 48, margin: 8, borderRadius: 4 }} /> - ) - } + ) + }
- {!this.state.reordering && ( + {(!this.state.reordering && !this.state.multiSelect) && ( <> @@ -1239,10 +1438,15 @@ export default withStyles( /> this.handleConfirmDelete()} onClose={() => { @@ -1296,7 +1500,7 @@ export default withStyles( onClose={() => { this.setState({ moveDialogOpen: false, copyDialogOpen: false }); }} onOk={ (path) => { - this.setState({ moveDialogOpen: false }); + this.setState({ moveDialogOpen: false, copyDialogOpen: false }); this.setDefaultPath(path); if (this.state.copyDialogOpen) { this.onExecuteCopy(path); @@ -1314,6 +1518,9 @@ export default withStyles( ); } openSelectedFile(): void { + if (this.state.multiSelect || this.state.reordering) { + return; + } if (this.isFolderArtwork(this.state.selectedFile)) { return; } @@ -1334,13 +1541,54 @@ export default withStyles( return pathFileNameOnly(name); } } - private onMove(): void { + private handleMove(): void { this.setState({ moveDialogOpen: true }); } - private onCopy(): void { + private handleCopy(): void { this.setState({ copyDialogOpen: true }); } + private async onExecuteMoveMulti(newDirectory: string) { + let files = this.state.selectedFiles.slice(); + let newFileList = this.state.selectedFiles.slice(); + try { + for (let file of files) { + let fileName = pathFileName(file); + let oldFilePath = pathConcat(this.state.navDirectory, fileName); + let newFilePath = pathConcat(newDirectory, fileName); + + await this.model.renameFilePropertyFile(oldFilePath, newFilePath, this.props.fileProperty); + let index = newFileList.indexOf(file); + if (index !== -1) { + newFileList.splice(index,1); + } + + } + } catch (e) { + this.model.showAlert(e); + } + // update state to refelect what we did. + if (!this.mounted) { + return; + } + if (newFileList.length === 0 ) + { + this.setState({ + multiSelect: false, + selectedFiles: [] + }); + } else { + this.setState({ + multiSelect: true, + selectedFiles: newFileList, + }); + } + this.requestFiles(this.state.navDirectory); + } private onExecuteMove(newDirectory: string) { + if (this.state.multiSelect) { + this.onExecuteMoveMulti(newDirectory); + return; + } let fileName = pathFileName(this.state.selectedFile); let oldFilePath = pathConcat(this.state.navDirectory, fileName); let newFilePath = pathConcat(newDirectory, fileName); @@ -1369,8 +1617,32 @@ export default withStyles( this.setState({ confirmCopyDialogState: null }); } } + private async onExecuteCopyMulti(newDirectory: string) { + + let files = this.state.selectedFiles.slice(); + let fileProperty = this.props.fileProperty; + try { + for (let fileName of files) { + let oldFilePath = pathConcat(this.state.navDirectory, fileName); + let newFilePath = pathConcat(newDirectory, fileName); + + await this.model.copyFilePropertyFile(oldFilePath, newFilePath, fileProperty, true); + } + } catch (e) { + this.model.showAlert(e); + } + // update state to reflect what we did. + if (!this.mounted) { + return; + } + this.requestFiles(this.state.navDirectory); + } private onExecuteCopy(newDirectory: string) { this.setState({ copyDialogOpen: false }); + if (this.state.multiSelect) { + this.onExecuteCopyMulti(newDirectory); + return; + } let fileName = pathFileName(this.state.selectedFile); let oldFilePath = pathConcat(this.state.navDirectory, fileName); let newFilePath = pathConcat(newDirectory, fileName); diff --git a/vite/src/pipedal/PiPedalModel.tsx b/vite/src/pipedal/PiPedalModel.tsx index e09da13..735fa1a 100644 --- a/vite/src/pipedal/PiPedalModel.tsx +++ b/vite/src/pipedal/PiPedalModel.tsx @@ -2019,8 +2019,8 @@ export class PiPedalModel //implements PiPedalModel return this.copyPreset(instanceId, -1); } - showAlert(message: string | Error): void { - let m = message; + showAlert(message: any): void { + let m: string; if (message instanceof Error) { let e = message as Error; if (e.message) {