From cc46d040ca0e5463a884a15a7d98e4351ea9fbc3 Mon Sep 17 00:00:00 2001 From: Will Scullin Date: Sun, 12 Jun 2022 09:42:01 -0700 Subject: [PATCH] Add drag and drop for disks (#130) * Add drag and drop for disks * Simplify storage state * Switch to spawn, add abort signal to loads --- js/components/Apple2.tsx | 9 ++- js/components/BlockDisk.tsx | 14 +++- js/components/BlockFileModal.tsx | 4 +- js/components/DiskDragTarget.tsx | 97 +++++++++++++++++++++++++++ js/components/DiskII.tsx | 14 +++- js/components/Drives.tsx | 78 +++++++++++++++------ js/components/FileModal.tsx | 6 +- js/components/css/Apple2.module.css | 5 ++ js/components/util/files.ts | 55 ++++++++------- test/components/util/promises.spec.ts | 14 ++-- 10 files changed, 231 insertions(+), 65 deletions(-) create mode 100644 js/components/DiskDragTarget.tsx diff --git a/js/components/Apple2.tsx b/js/components/Apple2.tsx index 82173ce..14e4e2e 100644 --- a/js/components/Apple2.tsx +++ b/js/components/Apple2.tsx @@ -45,6 +45,7 @@ export const Apple2 = (props: Apple2Props) => { const [io, setIO] = useState(); const [cpu, setCPU] = useState(); const [error, setError] = useState(); + const [ready, setReady] = useState(false); const drivesReady = useMemo(() => new Ready(setError), []); useEffect(() => { @@ -66,6 +67,9 @@ export const Apple2 = (props: Apple2Props) => { setCPU(apple2.getCPU()); await drivesReady.ready; if (signal.aborted) { + setApple2(undefined); + setIO(undefined); + setCPU(undefined); return; } apple2.reset(); @@ -73,13 +77,14 @@ export const Apple2 = (props: Apple2Props) => { } catch (e) { setError(e); } + setReady(true); }); - return controller.abort(); + return () => controller.abort(); } }, [props, drivesReady]); return ( -
+
diff --git a/js/components/BlockDisk.tsx b/js/components/BlockDisk.tsx index 4b1f478..56fa726 100644 --- a/js/components/BlockDisk.tsx +++ b/js/components/BlockDisk.tsx @@ -3,9 +3,11 @@ import { useCallback, useState } from 'preact/hooks'; import cs from 'classnames'; import SmartPort from '../cards/smartport'; import { BlockFileModal } from './BlockFileModal'; +import { DiskDragTarget } from './DiskDragTarget'; import { ErrorModal } from './ErrorModal'; import styles from './css/BlockDisk.module.css'; +import { BLOCK_FORMATS } from 'js/formats/types'; /** * Storage structure for drive state returned via callbacks. @@ -20,7 +22,7 @@ export interface BlockDiskData { * Interface for BlockDisk. */ export interface BlockDiskProps extends BlockDiskData { - smartPort: SmartPort | undefined; + smartPort: SmartPort; } /** @@ -48,7 +50,13 @@ export const BlockDisk = ({ smartPort, number, on, name }: BlockDiskProps) => { }, []); return ( -
+ { > {name}
-
+ ); }; diff --git a/js/components/BlockFileModal.tsx b/js/components/BlockFileModal.tsx index 6b76bb0..8b25413 100644 --- a/js/components/BlockFileModal.tsx +++ b/js/components/BlockFileModal.tsx @@ -20,7 +20,7 @@ const DISK_TYPES: FilePickerAcceptType[] = [ interface BlockFileModalProps { isOpen: boolean; - smartPort: SmartPort | undefined; + smartPort: SmartPort; number: DriveNumber; onClose: (closeBox?: boolean) => void; } @@ -37,7 +37,7 @@ export const BlockFileModal = ({ smartPort, number, onClose, isOpen } : BlockFil const doOpen = useCallback(async () => { const hashParts = getHashParts(hash); - if (smartPort && handles?.length === 1) { + if (handles?.length === 1) { hashParts[number] = ''; setBusy(true); try { diff --git a/js/components/DiskDragTarget.tsx b/js/components/DiskDragTarget.tsx new file mode 100644 index 0000000..2d2b805 --- /dev/null +++ b/js/components/DiskDragTarget.tsx @@ -0,0 +1,97 @@ +import { BLOCK_FORMATS, DISK_FORMATS, DriveNumber, MassStorage, NIBBLE_FORMATS } from 'js/formats/types'; +import { h, JSX, RefObject } from 'preact'; +import { useEffect, useRef } from 'preact/hooks'; +import { loadLocalFile } from './util/files'; +import { spawn } from './util/promises'; + +export interface DiskDragTargetProps extends JSX.HTMLAttributes { + storage: MassStorage | undefined; + drive?: DriveNumber; + formats: typeof NIBBLE_FORMATS + | typeof BLOCK_FORMATS + | typeof DISK_FORMATS; + dropRef?: RefObject; + onError: (error: unknown) => void; +} + +export const DiskDragTarget = ({ + storage, + drive, + dropRef, + formats, + onError, + children, + ...props +}: DiskDragTargetProps) => { + const ref = useRef(null); + + useEffect(() => { + const div = dropRef?.current || ref.current; + if (div) { + const onDragOver = (event: DragEvent) => { + event.preventDefault(); + const dt = event.dataTransfer; + if (dt) { + if (Array.from(dt.items).every((item) => item.kind === 'file')) { + dt.dropEffect = 'copy'; + } else { + dt.dropEffect = 'none'; + } + } + }; + + const onDragEnd = (event: DragEvent) => { + const dt = event.dataTransfer; + if (dt?.items) { + for (let i = 0; i < dt.items.length; i++) { + dt.items.remove(i); + } + } else { + dt?.clearData(); + } + }; + + const onDrop = (event: DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + const targetDrive = drive ?? 1; //TODO(whscullin) Maybe pick available drive + + const dt = event.dataTransfer; + if (dt?.files.length === 1 && storage) { + spawn(async () => { + try { + await loadLocalFile(storage, formats, targetDrive, dt.files[0]); + } catch (e) { + onError(e); + } + }); + } else if (dt?.files.length === 2 && storage) { + spawn(async () => { + try { + await loadLocalFile(storage, formats, 1, dt.files[0]); + await loadLocalFile(storage, formats, 2, dt.files[1]); + } catch (e) { + onError(e); + } + }); + } + }; + + div.addEventListener('dragover', onDragOver); + div.addEventListener('dragend', onDragEnd); + div.addEventListener('drop', onDrop); + + return () => { + div.removeEventListener('dragover', onDragOver); + div.removeEventListener('dragend', onDragEnd); + div.removeEventListener('drop', onDrop); + }; + } + }, [drive, dropRef, formats, onError, storage]); + + return ( +
+ {children} +
+ ); +}; diff --git a/js/components/DiskII.tsx b/js/components/DiskII.tsx index b1c5758..a443b5b 100644 --- a/js/components/DiskII.tsx +++ b/js/components/DiskII.tsx @@ -6,6 +6,8 @@ import { ErrorModal } from './ErrorModal'; import { FileModal } from './FileModal'; import styles from './css/DiskII.module.css'; +import { DiskDragTarget } from './DiskDragTarget'; +import { NIBBLE_FORMATS } from 'js/formats/types'; /** * Storage structure for Disk II state returned via callbacks. @@ -21,7 +23,7 @@ export interface DiskIIData { * Interface for Disk II component. */ export interface DiskIIProps extends DiskIIData { - disk2: Disk2 | undefined; + disk2: Disk2; } /** @@ -50,7 +52,13 @@ export const DiskII = ({ disk2, number, on, name, side }: DiskIIProps) => { }, []); return ( -
+
@@ -60,6 +68,6 @@ export const DiskII = ({ disk2, number, on, name, side }: DiskIIProps) => {
{label}
-
+
); }; diff --git a/js/components/Drives.tsx b/js/components/Drives.tsx index 22fd5c9..b5ca0ff 100644 --- a/js/components/Drives.tsx +++ b/js/components/Drives.tsx @@ -1,5 +1,5 @@ import { h } from 'preact'; -import { useCallback, useEffect, useState } from 'preact/hooks'; +import { useCallback, useEffect, useRef, useState } from 'preact/hooks'; import Disk2, { Callbacks } from '../cards/disk2'; import Apple2IO from '../apple2io'; import { DiskII, DiskIIData } from './DiskII'; @@ -8,12 +8,22 @@ import CPU6502 from 'js/cpu6502'; import { BlockDisk } from './BlockDisk'; import { ErrorModal } from './ErrorModal'; import { ProgressModal } from './ProgressModal'; -import { loadHttpUnknownFile, getHashParts, loadJSON } from './util/files'; +import { loadHttpUnknownFile, getHashParts, loadJSON, SmartStorageBroker } from './util/files'; import { useHash } from './hooks/useHash'; -import { DriveNumber } from 'js/formats/types'; -import { Ready } from './util/promises'; +import { DISK_FORMATS, DriveNumber } from 'js/formats/types'; +import { spawn, Ready } from './util/promises'; import styles from './css/Drives.module.css'; +import { DiskDragTarget } from './DiskDragTarget'; + +/** + * Storage device storage + */ +interface StorageDevices { + disk2: Disk2; + smartPort: SmartPort; + smartStorageBroker: SmartStorageBroker; +} /** * Interface for Drives component. @@ -43,12 +53,14 @@ export const Drives = ({ cpu, io, sectors, enhanced, ready }: DrivesProps) => { const [current, setCurrent] = useState(0); const [error, setError] = useState(); const [total, setTotal] = useState(0); + const bodyRef = useRef(document.body); const onProgress = useCallback((current: number, total: number) => { setCurrent(current); setTotal(total); }, []); - const [disk2, setDisk2] = useState(); + const [storageDevices, setStorageDevices] = useState(); + const [data1, setData1] = useState({ on: false, number: 1, @@ -70,28 +82,36 @@ export const Drives = ({ cpu, io, sectors, enhanced, ready }: DrivesProps) => { name: 'HD 2' }); - const [smartPort, setSmartPort] = useState(); - const hash = useHash(); useEffect(() => { - if (smartPort && disk2) { + if (storageDevices) { + const { smartStorageBroker, disk2 } = storageDevices; const hashParts = getHashParts(hash); + const controllers: AbortController[] = []; let loading = 0; for (const drive of [1, 2] as DriveNumber[]) { if (hashParts && hashParts[drive]) { const hashPart = decodeURIComponent(hashParts[drive]); if (hashPart.match(/^https?:/)) { loading++; - loadHttpUnknownFile(disk2, smartPort, drive, hashPart, onProgress) - .catch((e) => setError(e)) - .finally(() => { - if (--loading === 0) { - ready.onReady(); - } - setCurrent(0); - setTotal(0); - }); + controllers.push(spawn(async (signal) => { + try { + await loadHttpUnknownFile( + smartStorageBroker, + drive, + hashPart, + signal, + onProgress); + } catch (e) { + setError(e); + } + if (--loading === 0) { + ready.onReady(); + } + setCurrent(0); + setTotal(0); + })); } else { const url = `/json/disks/${hashPart}.json`; loadJSON(disk2, drive, url).catch((e) => setError(e)); @@ -101,8 +121,9 @@ export const Drives = ({ cpu, io, sectors, enhanced, ready }: DrivesProps) => { if (!loading) { ready.onReady(); } + return () => controllers.forEach((controller) => controller.abort()); } - }, [hash, onProgress, disk2, ready, smartPort]); + }, [hash, onProgress, ready, storageDevices]); useEffect(() => { const setData = [setData1, setData2]; @@ -140,15 +161,28 @@ export const Drives = ({ cpu, io, sectors, enhanced, ready }: DrivesProps) => { if (cpu && io) { const disk2 = new Disk2(io, callbacks, sectors); io.setSlot(6, disk2); - setDisk2(disk2); const smartPort = new SmartPort(cpu, smartPortCallbacks, { block: !enhanced }); io.setSlot(7, smartPort); - setSmartPort(smartPort); + + const smartStorageBroker = new SmartStorageBroker(disk2, smartPort); + setStorageDevices({ disk2, smartPort, smartStorageBroker }); } }, [cpu, enhanced, io, sectors]); + if (!storageDevices) { + return null; + } + + const { disk2, smartPort, smartStorageBroker } = storageDevices; + return ( -
+
@@ -159,6 +193,6 @@ export const Drives = ({ cpu, io, sectors, enhanced, ready }: DrivesProps) => {
-
+ ); }; diff --git a/js/components/FileModal.tsx b/js/components/FileModal.tsx index d7c95b8..79e9468 100644 --- a/js/components/FileModal.tsx +++ b/js/components/FileModal.tsx @@ -43,7 +43,7 @@ export type NibbleFileCallback = ( interface FileModalProps { isOpen: boolean; - disk2: DiskII | undefined; + disk2: DiskII; number: DriveNumber; onClose: (closeBox?: boolean) => void; } @@ -64,11 +64,11 @@ export const FileModal = ({ disk2, number, onClose, isOpen }: FileModalProps) => setBusy(true); try { - if (disk2 && handles?.length === 1) { + if (handles?.length === 1) { hashParts[number] = ''; await loadLocalNibbleFile(disk2, number, await handles[0].getFile()); } - if (disk2 && filename) { + if (filename) { const name = filename.match(/\/([^/]+).json$/) || ['', '']; hashParts[number] = name[1]; await loadJSON(disk2, number, filename); diff --git a/js/components/css/Apple2.module.css b/js/components/css/Apple2.module.css index 8d489f6..b67b81a 100644 --- a/js/components/css/Apple2.module.css +++ b/js/components/css/Apple2.module.css @@ -1,4 +1,9 @@ .outer { margin: auto; width: 620px; + display: none; +} + +.outer.ready { + display: block; } diff --git a/js/components/util/files.ts b/js/components/util/files.ts index a23fee6..1ac9bd9 100644 --- a/js/components/util/files.ts +++ b/js/components/util/files.ts @@ -46,7 +46,7 @@ export const getNameAndExtension = (url: string) => { export const loadLocalFile = ( storage: MassStorage, - formats: typeof NIBBLE_FORMATS | typeof BLOCK_FORMATS, + formats: typeof NIBBLE_FORMATS | typeof BLOCK_FORMATS | typeof DISK_FORMATS, number: DriveNumber, file: File, ) => { @@ -54,10 +54,7 @@ export const loadLocalFile = ( const fileReader = new FileReader(); fileReader.onload = function () { const result = this.result as ArrayBuffer; - const parts = file.name.split('.'); - const ext = parts.pop()?.toLowerCase() || '[none]'; - const name = parts.join('.'); - + const { name, ext } = getNameAndExtension(file.name); if (includes(formats, ext)) { initGamepad(); if (storage.setBinary(number, name, ext, result)) { @@ -128,9 +125,10 @@ export const loadJSON = async ( export const loadHttpFile = async ( url: string, - onProgress?: ProgressCallback + signal?: AbortSignal, + onProgress?: ProgressCallback, ): Promise => { - const response = await fetch(url); + const response = await fetch(url, signal ? { signal } : {}); if (!response.ok) { throw new Error(`Error loading: ${response.statusText}`); } @@ -175,13 +173,14 @@ export const loadHttpBlockFile = async ( smartPort: SmartPort, number: DriveNumber, url: string, + signal?: AbortSignal, onProgress?: ProgressCallback ): Promise => { const { name, ext } = getNameAndExtension(url); if (!includes(BLOCK_FORMATS, ext)) { throw new Error(`Extension "${ext}" not recognized.`); } - const data = await loadHttpFile(url, onProgress); + const data = await loadHttpFile(url, signal, onProgress); smartPort.setBinary(number, name, ext, data); initGamepad(); @@ -202,6 +201,7 @@ export const loadHttpNibbleFile = async ( disk2: Disk2, number: DriveNumber, url: string, + signal?: AbortSignal, onProgress?: ProgressCallback ) => { if (url.endsWith('.json')) { @@ -211,34 +211,43 @@ export const loadHttpNibbleFile = async ( if (!includes(NIBBLE_FORMATS, ext)) { throw new Error(`Extension "${ext}" not recognized.`); } - const data = await loadHttpFile(url, onProgress); + const data = await loadHttpFile(url, signal, onProgress); disk2.setBinary(number, name, ext, data); initGamepad(); - return loadHttpFile(url, onProgress); + return loadHttpFile(url, signal, onProgress); }; export const loadHttpUnknownFile = async ( - disk2: Disk2, - smartPort: SmartPort, + smartStorageBroker: SmartStorageBroker, number: DriveNumber, url: string, + signal?: AbortSignal, onProgress?: ProgressCallback, ) => { - const data = await loadHttpFile(url, onProgress); + const data = await loadHttpFile(url, signal, onProgress); const { name, ext } = getNameAndExtension(url); - if (includes(DISK_FORMATS, ext)) { - if (data.byteLength >= 800 * 1024) { - if (includes(BLOCK_FORMATS, ext)) { - smartPort.setBinary(number, name, ext, data); + smartStorageBroker.setBinary(number, name, ext, data); +}; + +export class SmartStorageBroker implements MassStorage { + constructor(private disk2: Disk2, private smartPort: SmartPort) {} + + setBinary(drive: DriveNumber, name: string, ext: string, data: ArrayBuffer): boolean { + if (includes(DISK_FORMATS, ext)) { + if (data.byteLength >= 800 * 1024) { + if (includes(BLOCK_FORMATS, ext)) { + this.smartPort.setBinary(drive, name, ext, data); + } else { + throw new Error(`Unable to load "${name}"`); + } + } else if (includes(NIBBLE_FORMATS, ext)) { + this.disk2.setBinary(drive, name, ext, data); } else { throw new Error(`Unable to load "${name}"`); } - } else if (includes(NIBBLE_FORMATS, ext)) { - disk2.setBinary(number, name, ext, data); } else { - throw new Error(`Unable to load "${name}"`); + throw new Error(`Extension "${ext}" not recognized.`); } - } else { - throw new Error(`Extension "${ext}" not recognized.`); + return true; } -}; +} diff --git a/test/components/util/promises.spec.ts b/test/components/util/promises.spec.ts index 41b2481..df614ed 100644 --- a/test/components/util/promises.spec.ts +++ b/test/components/util/promises.spec.ts @@ -25,26 +25,26 @@ describe('promises', () => { const controllerIsAborted = new Ready(); const spawnHasRecorded = new Ready(); - + const controller = spawn(async (signal) => { await controllerIsAborted.ready; isAborted = signal.aborted; spawnHasRecorded.onReady(); }); - + controller.abort(); controllerIsAborted.onReady(); - + await spawnHasRecorded.ready; expect(isAborted).toBe(true); }); - it('allows long-runing tasks to be stopped', async () => { + it('allows long-running tasks to be stopped', async () => { let isFinished = false; const innerReady = new Ready(); const innerFinished = new Ready(); - + const controller = spawn(async (signal) => { innerReady.onReady(); let i = 0; @@ -52,8 +52,8 @@ describe('promises', () => { i++; await tick(); } + expect(i).toBe(2); isFinished = true; - console.log(i); innerFinished.onReady(); }); await innerReady.ready; @@ -103,4 +103,4 @@ describe('promises', () => { function tick() { return new Promise(resolve => setTimeout(resolve, 0)); -} \ No newline at end of file +}