Add drag and drop for disks (#130)

* Add drag and drop for disks

* Simplify storage state

* Switch to spawn, add abort signal to loads
This commit is contained in:
Will Scullin 2022-06-12 09:42:01 -07:00 committed by GitHub
parent 3048ec52e1
commit cc46d040ca
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 231 additions and 65 deletions

View File

@ -45,6 +45,7 @@ export const Apple2 = (props: Apple2Props) => {
const [io, setIO] = useState<Apple2IO>(); const [io, setIO] = useState<Apple2IO>();
const [cpu, setCPU] = useState<CPU6502>(); const [cpu, setCPU] = useState<CPU6502>();
const [error, setError] = useState<unknown>(); const [error, setError] = useState<unknown>();
const [ready, setReady] = useState(false);
const drivesReady = useMemo(() => new Ready(setError), []); const drivesReady = useMemo(() => new Ready(setError), []);
useEffect(() => { useEffect(() => {
@ -66,6 +67,9 @@ export const Apple2 = (props: Apple2Props) => {
setCPU(apple2.getCPU()); setCPU(apple2.getCPU());
await drivesReady.ready; await drivesReady.ready;
if (signal.aborted) { if (signal.aborted) {
setApple2(undefined);
setIO(undefined);
setCPU(undefined);
return; return;
} }
apple2.reset(); apple2.reset();
@ -73,13 +77,14 @@ export const Apple2 = (props: Apple2Props) => {
} catch (e) { } catch (e) {
setError(e); setError(e);
} }
setReady(true);
}); });
return controller.abort(); return () => controller.abort();
} }
}, [props, drivesReady]); }, [props, drivesReady]);
return ( return (
<div className={cs(styles.outer, { apple2e: e })}> <div className={cs(styles.outer, { apple2e: e, [styles.ready]: ready })}>
<Screen screen={screen} /> <Screen screen={screen} />
<Slinky io={io} slot={2} /> <Slinky io={io} slot={2} />
<Mouse cpu={cpu} screen={screen} io={io} slot={4} /> <Mouse cpu={cpu} screen={screen} io={io} slot={4} />

View File

@ -3,9 +3,11 @@ import { useCallback, useState } from 'preact/hooks';
import cs from 'classnames'; import cs from 'classnames';
import SmartPort from '../cards/smartport'; import SmartPort from '../cards/smartport';
import { BlockFileModal } from './BlockFileModal'; import { BlockFileModal } from './BlockFileModal';
import { DiskDragTarget } from './DiskDragTarget';
import { ErrorModal } from './ErrorModal'; import { ErrorModal } from './ErrorModal';
import styles from './css/BlockDisk.module.css'; import styles from './css/BlockDisk.module.css';
import { BLOCK_FORMATS } from 'js/formats/types';
/** /**
* Storage structure for drive state returned via callbacks. * Storage structure for drive state returned via callbacks.
@ -20,7 +22,7 @@ export interface BlockDiskData {
* Interface for BlockDisk. * Interface for BlockDisk.
*/ */
export interface BlockDiskProps extends BlockDiskData { export interface BlockDiskProps extends BlockDiskData {
smartPort: SmartPort | undefined; smartPort: SmartPort;
} }
/** /**
@ -48,7 +50,13 @@ export const BlockDisk = ({ smartPort, number, on, name }: BlockDiskProps) => {
}, []); }, []);
return ( return (
<div className={styles.disk}> <DiskDragTarget
className={styles.disk}
storage={smartPort}
drive={number}
formats={BLOCK_FORMATS}
onError={setError}
>
<ErrorModal error={error} setError={setError} /> <ErrorModal error={error} setError={setError} />
<BlockFileModal <BlockFileModal
smartPort={smartPort} smartPort={smartPort}
@ -69,6 +77,6 @@ export const BlockDisk = ({ smartPort, number, on, name }: BlockDiskProps) => {
> >
{name} {name}
</div> </div>
</div> </DiskDragTarget>
); );
}; };

View File

@ -20,7 +20,7 @@ const DISK_TYPES: FilePickerAcceptType[] = [
interface BlockFileModalProps { interface BlockFileModalProps {
isOpen: boolean; isOpen: boolean;
smartPort: SmartPort | undefined; smartPort: SmartPort;
number: DriveNumber; number: DriveNumber;
onClose: (closeBox?: boolean) => void; onClose: (closeBox?: boolean) => void;
} }
@ -37,7 +37,7 @@ export const BlockFileModal = ({ smartPort, number, onClose, isOpen } : BlockFil
const doOpen = useCallback(async () => { const doOpen = useCallback(async () => {
const hashParts = getHashParts(hash); const hashParts = getHashParts(hash);
if (smartPort && handles?.length === 1) { if (handles?.length === 1) {
hashParts[number] = ''; hashParts[number] = '';
setBusy(true); setBusy(true);
try { try {

View File

@ -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<T> extends JSX.HTMLAttributes<HTMLDivElement> {
storage: MassStorage<T> | undefined;
drive?: DriveNumber;
formats: typeof NIBBLE_FORMATS
| typeof BLOCK_FORMATS
| typeof DISK_FORMATS;
dropRef?: RefObject<HTMLElement>;
onError: (error: unknown) => void;
}
export const DiskDragTarget = ({
storage,
drive,
dropRef,
formats,
onError,
children,
...props
}: DiskDragTargetProps<unknown>) => {
const ref = useRef<HTMLDivElement>(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 (
<div ref={ref} {...props}>
{children}
</div>
);
};

View File

@ -6,6 +6,8 @@ import { ErrorModal } from './ErrorModal';
import { FileModal } from './FileModal'; import { FileModal } from './FileModal';
import styles from './css/DiskII.module.css'; 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. * Storage structure for Disk II state returned via callbacks.
@ -21,7 +23,7 @@ export interface DiskIIData {
* Interface for Disk II component. * Interface for Disk II component.
*/ */
export interface DiskIIProps extends DiskIIData { export interface DiskIIProps extends DiskIIData {
disk2: Disk2 | undefined; disk2: Disk2;
} }
/** /**
@ -50,7 +52,13 @@ export const DiskII = ({ disk2, number, on, name, side }: DiskIIProps) => {
}, []); }, []);
return ( return (
<div className={styles.disk}> <DiskDragTarget
className={styles.disk}
storage={disk2}
drive={number}
formats={NIBBLE_FORMATS}
onError={setError}
>
<FileModal disk2={disk2} number={number} onClose={doClose} isOpen={modalOpen} /> <FileModal disk2={disk2} number={number} onClose={doClose} isOpen={modalOpen} />
<ErrorModal error={error} setError={setError} /> <ErrorModal error={error} setError={setError} />
<div className={cs(styles.diskLight, { [styles.on]: on })} /> <div className={cs(styles.diskLight, { [styles.on]: on })} />
@ -60,6 +68,6 @@ export const DiskII = ({ disk2, number, on, name, side }: DiskIIProps) => {
<div className={styles.diskLabel}> <div className={styles.diskLabel}>
{label} {label}
</div> </div>
</div> </DiskDragTarget>
); );
}; };

View File

@ -1,5 +1,5 @@
import { h } from 'preact'; 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 Disk2, { Callbacks } from '../cards/disk2';
import Apple2IO from '../apple2io'; import Apple2IO from '../apple2io';
import { DiskII, DiskIIData } from './DiskII'; import { DiskII, DiskIIData } from './DiskII';
@ -8,12 +8,22 @@ import CPU6502 from 'js/cpu6502';
import { BlockDisk } from './BlockDisk'; import { BlockDisk } from './BlockDisk';
import { ErrorModal } from './ErrorModal'; import { ErrorModal } from './ErrorModal';
import { ProgressModal } from './ProgressModal'; 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 { useHash } from './hooks/useHash';
import { DriveNumber } from 'js/formats/types'; import { DISK_FORMATS, DriveNumber } from 'js/formats/types';
import { Ready } from './util/promises'; import { spawn, Ready } from './util/promises';
import styles from './css/Drives.module.css'; 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. * Interface for Drives component.
@ -43,12 +53,14 @@ export const Drives = ({ cpu, io, sectors, enhanced, ready }: DrivesProps) => {
const [current, setCurrent] = useState(0); const [current, setCurrent] = useState(0);
const [error, setError] = useState<unknown>(); const [error, setError] = useState<unknown>();
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const bodyRef = useRef(document.body);
const onProgress = useCallback((current: number, total: number) => { const onProgress = useCallback((current: number, total: number) => {
setCurrent(current); setCurrent(current);
setTotal(total); setTotal(total);
}, []); }, []);
const [disk2, setDisk2] = useState<Disk2>(); const [storageDevices, setStorageDevices] = useState<StorageDevices>();
const [data1, setData1] = useState<DiskIIData>({ const [data1, setData1] = useState<DiskIIData>({
on: false, on: false,
number: 1, number: 1,
@ -70,28 +82,36 @@ export const Drives = ({ cpu, io, sectors, enhanced, ready }: DrivesProps) => {
name: 'HD 2' name: 'HD 2'
}); });
const [smartPort, setSmartPort] = useState<SmartPort>();
const hash = useHash(); const hash = useHash();
useEffect(() => { useEffect(() => {
if (smartPort && disk2) { if (storageDevices) {
const { smartStorageBroker, disk2 } = storageDevices;
const hashParts = getHashParts(hash); const hashParts = getHashParts(hash);
const controllers: AbortController[] = [];
let loading = 0; let loading = 0;
for (const drive of [1, 2] as DriveNumber[]) { for (const drive of [1, 2] as DriveNumber[]) {
if (hashParts && hashParts[drive]) { if (hashParts && hashParts[drive]) {
const hashPart = decodeURIComponent(hashParts[drive]); const hashPart = decodeURIComponent(hashParts[drive]);
if (hashPart.match(/^https?:/)) { if (hashPart.match(/^https?:/)) {
loading++; loading++;
loadHttpUnknownFile(disk2, smartPort, drive, hashPart, onProgress) controllers.push(spawn(async (signal) => {
.catch((e) => setError(e)) try {
.finally(() => { await loadHttpUnknownFile(
if (--loading === 0) { smartStorageBroker,
ready.onReady(); drive,
} hashPart,
setCurrent(0); signal,
setTotal(0); onProgress);
}); } catch (e) {
setError(e);
}
if (--loading === 0) {
ready.onReady();
}
setCurrent(0);
setTotal(0);
}));
} else { } else {
const url = `/json/disks/${hashPart}.json`; const url = `/json/disks/${hashPart}.json`;
loadJSON(disk2, drive, url).catch((e) => setError(e)); loadJSON(disk2, drive, url).catch((e) => setError(e));
@ -101,8 +121,9 @@ export const Drives = ({ cpu, io, sectors, enhanced, ready }: DrivesProps) => {
if (!loading) { if (!loading) {
ready.onReady(); ready.onReady();
} }
return () => controllers.forEach((controller) => controller.abort());
} }
}, [hash, onProgress, disk2, ready, smartPort]); }, [hash, onProgress, ready, storageDevices]);
useEffect(() => { useEffect(() => {
const setData = [setData1, setData2]; const setData = [setData1, setData2];
@ -140,15 +161,28 @@ export const Drives = ({ cpu, io, sectors, enhanced, ready }: DrivesProps) => {
if (cpu && io) { if (cpu && io) {
const disk2 = new Disk2(io, callbacks, sectors); const disk2 = new Disk2(io, callbacks, sectors);
io.setSlot(6, disk2); io.setSlot(6, disk2);
setDisk2(disk2);
const smartPort = new SmartPort(cpu, smartPortCallbacks, { block: !enhanced }); const smartPort = new SmartPort(cpu, smartPortCallbacks, { block: !enhanced });
io.setSlot(7, smartPort); io.setSlot(7, smartPort);
setSmartPort(smartPort);
const smartStorageBroker = new SmartStorageBroker(disk2, smartPort);
setStorageDevices({ disk2, smartPort, smartStorageBroker });
} }
}, [cpu, enhanced, io, sectors]); }, [cpu, enhanced, io, sectors]);
if (!storageDevices) {
return null;
}
const { disk2, smartPort, smartStorageBroker } = storageDevices;
return ( return (
<div className={styles.drives}> <DiskDragTarget
storage={smartStorageBroker}
dropRef={bodyRef}
className={styles.drives}
onError={setError}
formats={DISK_FORMATS}
>
<ProgressModal current={current} total={total} title="Loading..." /> <ProgressModal current={current} total={total} title="Loading..." />
<ErrorModal error={error} setError={setError} /> <ErrorModal error={error} setError={setError} />
<div className={styles.driveBay}> <div className={styles.driveBay}>
@ -159,6 +193,6 @@ export const Drives = ({ cpu, io, sectors, enhanced, ready }: DrivesProps) => {
<BlockDisk smartPort={smartPort} {...smartData1} /> <BlockDisk smartPort={smartPort} {...smartData1} />
<BlockDisk smartPort={smartPort} {...smartData2} /> <BlockDisk smartPort={smartPort} {...smartData2} />
</div> </div>
</div> </DiskDragTarget>
); );
}; };

View File

@ -43,7 +43,7 @@ export type NibbleFileCallback = (
interface FileModalProps { interface FileModalProps {
isOpen: boolean; isOpen: boolean;
disk2: DiskII | undefined; disk2: DiskII;
number: DriveNumber; number: DriveNumber;
onClose: (closeBox?: boolean) => void; onClose: (closeBox?: boolean) => void;
} }
@ -64,11 +64,11 @@ export const FileModal = ({ disk2, number, onClose, isOpen }: FileModalProps) =>
setBusy(true); setBusy(true);
try { try {
if (disk2 && handles?.length === 1) { if (handles?.length === 1) {
hashParts[number] = ''; hashParts[number] = '';
await loadLocalNibbleFile(disk2, number, await handles[0].getFile()); await loadLocalNibbleFile(disk2, number, await handles[0].getFile());
} }
if (disk2 && filename) { if (filename) {
const name = filename.match(/\/([^/]+).json$/) || ['', '']; const name = filename.match(/\/([^/]+).json$/) || ['', ''];
hashParts[number] = name[1]; hashParts[number] = name[1];
await loadJSON(disk2, number, filename); await loadJSON(disk2, number, filename);

View File

@ -1,4 +1,9 @@
.outer { .outer {
margin: auto; margin: auto;
width: 620px; width: 620px;
display: none;
}
.outer.ready {
display: block;
} }

View File

@ -46,7 +46,7 @@ export const getNameAndExtension = (url: string) => {
export const loadLocalFile = ( export const loadLocalFile = (
storage: MassStorage<NibbleFormat|BlockFormat>, storage: MassStorage<NibbleFormat|BlockFormat>,
formats: typeof NIBBLE_FORMATS | typeof BLOCK_FORMATS, formats: typeof NIBBLE_FORMATS | typeof BLOCK_FORMATS | typeof DISK_FORMATS,
number: DriveNumber, number: DriveNumber,
file: File, file: File,
) => { ) => {
@ -54,10 +54,7 @@ export const loadLocalFile = (
const fileReader = new FileReader(); const fileReader = new FileReader();
fileReader.onload = function () { fileReader.onload = function () {
const result = this.result as ArrayBuffer; const result = this.result as ArrayBuffer;
const parts = file.name.split('.'); const { name, ext } = getNameAndExtension(file.name);
const ext = parts.pop()?.toLowerCase() || '[none]';
const name = parts.join('.');
if (includes(formats, ext)) { if (includes(formats, ext)) {
initGamepad(); initGamepad();
if (storage.setBinary(number, name, ext, result)) { if (storage.setBinary(number, name, ext, result)) {
@ -128,9 +125,10 @@ export const loadJSON = async (
export const loadHttpFile = async ( export const loadHttpFile = async (
url: string, url: string,
onProgress?: ProgressCallback signal?: AbortSignal,
onProgress?: ProgressCallback,
): Promise<ArrayBuffer> => { ): Promise<ArrayBuffer> => {
const response = await fetch(url); const response = await fetch(url, signal ? { signal } : {});
if (!response.ok) { if (!response.ok) {
throw new Error(`Error loading: ${response.statusText}`); throw new Error(`Error loading: ${response.statusText}`);
} }
@ -175,13 +173,14 @@ export const loadHttpBlockFile = async (
smartPort: SmartPort, smartPort: SmartPort,
number: DriveNumber, number: DriveNumber,
url: string, url: string,
signal?: AbortSignal,
onProgress?: ProgressCallback onProgress?: ProgressCallback
): Promise<boolean> => { ): Promise<boolean> => {
const { name, ext } = getNameAndExtension(url); const { name, ext } = getNameAndExtension(url);
if (!includes(BLOCK_FORMATS, ext)) { if (!includes(BLOCK_FORMATS, ext)) {
throw new Error(`Extension "${ext}" not recognized.`); 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); smartPort.setBinary(number, name, ext, data);
initGamepad(); initGamepad();
@ -202,6 +201,7 @@ export const loadHttpNibbleFile = async (
disk2: Disk2, disk2: Disk2,
number: DriveNumber, number: DriveNumber,
url: string, url: string,
signal?: AbortSignal,
onProgress?: ProgressCallback onProgress?: ProgressCallback
) => { ) => {
if (url.endsWith('.json')) { if (url.endsWith('.json')) {
@ -211,34 +211,43 @@ export const loadHttpNibbleFile = async (
if (!includes(NIBBLE_FORMATS, ext)) { if (!includes(NIBBLE_FORMATS, ext)) {
throw new Error(`Extension "${ext}" not recognized.`); 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); disk2.setBinary(number, name, ext, data);
initGamepad(); initGamepad();
return loadHttpFile(url, onProgress); return loadHttpFile(url, signal, onProgress);
}; };
export const loadHttpUnknownFile = async ( export const loadHttpUnknownFile = async (
disk2: Disk2, smartStorageBroker: SmartStorageBroker,
smartPort: SmartPort,
number: DriveNumber, number: DriveNumber,
url: string, url: string,
signal?: AbortSignal,
onProgress?: ProgressCallback, onProgress?: ProgressCallback,
) => { ) => {
const data = await loadHttpFile(url, onProgress); const data = await loadHttpFile(url, signal, onProgress);
const { name, ext } = getNameAndExtension(url); const { name, ext } = getNameAndExtension(url);
if (includes(DISK_FORMATS, ext)) { smartStorageBroker.setBinary(number, name, ext, data);
if (data.byteLength >= 800 * 1024) { };
if (includes(BLOCK_FORMATS, ext)) {
smartPort.setBinary(number, name, ext, data); export class SmartStorageBroker implements MassStorage<unknown> {
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 { } else {
throw new Error(`Unable to load "${name}"`); throw new Error(`Unable to load "${name}"`);
} }
} else if (includes(NIBBLE_FORMATS, ext)) {
disk2.setBinary(number, name, ext, data);
} else { } else {
throw new Error(`Unable to load "${name}"`); throw new Error(`Extension "${ext}" not recognized.`);
} }
} else { return true;
throw new Error(`Extension "${ext}" not recognized.`);
} }
}; }

View File

@ -25,26 +25,26 @@ describe('promises', () => {
const controllerIsAborted = new Ready(); const controllerIsAborted = new Ready();
const spawnHasRecorded = new Ready(); const spawnHasRecorded = new Ready();
const controller = spawn(async (signal) => { const controller = spawn(async (signal) => {
await controllerIsAborted.ready; await controllerIsAborted.ready;
isAborted = signal.aborted; isAborted = signal.aborted;
spawnHasRecorded.onReady(); spawnHasRecorded.onReady();
}); });
controller.abort(); controller.abort();
controllerIsAborted.onReady(); controllerIsAborted.onReady();
await spawnHasRecorded.ready; await spawnHasRecorded.ready;
expect(isAborted).toBe(true); 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; let isFinished = false;
const innerReady = new Ready(); const innerReady = new Ready();
const innerFinished = new Ready(); const innerFinished = new Ready();
const controller = spawn(async (signal) => { const controller = spawn(async (signal) => {
innerReady.onReady(); innerReady.onReady();
let i = 0; let i = 0;
@ -52,8 +52,8 @@ describe('promises', () => {
i++; i++;
await tick(); await tick();
} }
expect(i).toBe(2);
isFinished = true; isFinished = true;
console.log(i);
innerFinished.onReady(); innerFinished.onReady();
}); });
await innerReady.ready; await innerReady.ready;
@ -103,4 +103,4 @@ describe('promises', () => {
function tick() { function tick() {
return new Promise(resolve => setTimeout(resolve, 0)); return new Promise(resolve => setTimeout(resolve, 0));
} }