Preact mass storage (#125)
The major impetus for rewriting in UI, at least. Still some ironing to do, but much nicer than my attempt to do this using the old UI "framework".
@ -31,7 +31,6 @@
|
|||||||
],
|
],
|
||||||
"no-var": "error",
|
"no-var": "error",
|
||||||
"no-use-before-define": "off",
|
"no-use-before-define": "off",
|
||||||
"no-dupe-class-members": "off",
|
|
||||||
"no-console": [
|
"no-console": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
@ -213,4 +212,4 @@
|
|||||||
"version": "16"
|
"version": "16"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
BIN
css/green-off-16.png
Normal file
After Width: | Height: | Size: 784 B |
BIN
css/green-off-32.png
Normal file
After Width: | Height: | Size: 1.9 KiB |
BIN
css/green-on-16.png
Normal file
After Width: | Height: | Size: 919 B |
BIN
css/green-on-32.png
Normal file
After Width: | Height: | Size: 2.5 KiB |
0
css/red-off-16.png
Executable file → Normal file
Before Width: | Height: | Size: 764 B After Width: | Height: | Size: 764 B |
BIN
css/red-off-32.png
Normal file
After Width: | Height: | Size: 1.9 KiB |
0
css/red-on-16.png
Executable file → Normal file
Before Width: | Height: | Size: 869 B After Width: | Height: | Size: 869 B |
BIN
css/red-on-32.png
Normal file
After Width: | Height: | Size: 2.3 KiB |
@ -85,7 +85,7 @@ export interface CFFAState {
|
|||||||
disks: Array<BlockDisk | null>;
|
disks: Array<BlockDisk | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class CFFA implements Card, MassStorage, Restorable<CFFAState> {
|
export default class CFFA implements Card, MassStorage<BlockFormat>, Restorable<CFFAState> {
|
||||||
|
|
||||||
// CFFA internal Flags
|
// CFFA internal Flags
|
||||||
|
|
||||||
|
@ -1,12 +1,13 @@
|
|||||||
import { debug, toHex } from '../util';
|
import { debug, toHex } from '../util';
|
||||||
import { rom as smartPortRom } from '../roms/cards/smartport';
|
import { rom as smartPortRom } from '../roms/cards/smartport';
|
||||||
import { Card, Restorable, byte, word, rom } from '../types';
|
import { Card, Restorable, byte, word, rom } from '../types';
|
||||||
import { MassStorage, BlockDisk, ENCODING_BLOCK } from '../formats/types';
|
import { MassStorage, BlockDisk, ENCODING_BLOCK, BlockFormat } from '../formats/types';
|
||||||
import CPU6502, { CpuState, flags } from '../cpu6502';
|
import CPU6502, { CpuState, flags } from '../cpu6502';
|
||||||
import { read2MGHeader } from '../formats/2mg';
|
import { read2MGHeader } from '../formats/2mg';
|
||||||
import createBlockDisk from '../formats/block';
|
import createBlockDisk from '../formats/block';
|
||||||
import { ProDOSVolume } from '../formats/prodos';
|
import { ProDOSVolume } from '../formats/prodos';
|
||||||
import { dump } from '../formats/prodos/utils';
|
import { dump } from '../formats/prodos/utils';
|
||||||
|
import { DriveNumber } from '../formats/types';
|
||||||
|
|
||||||
const ID = 'SMARTPORT.J.S';
|
const ID = 'SMARTPORT.J.S';
|
||||||
|
|
||||||
@ -18,6 +19,12 @@ export interface SmartPortOptions {
|
|||||||
block: boolean;
|
block: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Callbacks {
|
||||||
|
driveLight: (drive: DriveNumber, on: boolean) => void;
|
||||||
|
dirty: (drive: DriveNumber, dirty: boolean) => void;
|
||||||
|
label: (drive: DriveNumber, name?: string, side?: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
class Address {
|
class Address {
|
||||||
lo: byte;
|
lo: byte;
|
||||||
hi: byte;
|
hi: byte;
|
||||||
@ -118,12 +125,18 @@ const DEVICE_TYPE_SCSI_HD = 0x07;
|
|||||||
// $0D: Printer
|
// $0D: Printer
|
||||||
// $0E: Clock
|
// $0E: Clock
|
||||||
// $0F: Modem
|
// $0F: Modem
|
||||||
export default class SmartPort implements Card, MassStorage, Restorable<SmartPortState> {
|
export default class SmartPort implements Card, MassStorage<BlockFormat>, Restorable<SmartPortState> {
|
||||||
|
|
||||||
private rom: rom;
|
private rom: rom;
|
||||||
private disks: BlockDisk[] = [];
|
private disks: BlockDisk[] = [];
|
||||||
|
private busy: boolean[] = [];
|
||||||
|
private busyTimeout: ReturnType<typeof setTimeout>[] = [];
|
||||||
|
|
||||||
constructor(private cpu: CPU6502, options: SmartPortOptions) {
|
constructor(
|
||||||
|
private cpu: CPU6502,
|
||||||
|
private callbacks: Callbacks | null,
|
||||||
|
options: SmartPortOptions
|
||||||
|
) {
|
||||||
if (options?.block) {
|
if (options?.block) {
|
||||||
const dumbPortRom = new Uint8Array(smartPortRom);
|
const dumbPortRom = new Uint8Array(smartPortRom);
|
||||||
dumbPortRom[0x07] = 0x3C;
|
dumbPortRom[0x07] = 0x3C;
|
||||||
@ -139,11 +152,23 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
|
|||||||
// debug.apply(this, arguments);
|
// debug.apply(this, arguments);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private driveLight(drive: DriveNumber) {
|
||||||
|
if (!this.busy[drive]) {
|
||||||
|
this.busy[drive] = true;
|
||||||
|
this.callbacks?.driveLight(drive, true);
|
||||||
|
}
|
||||||
|
clearTimeout(this.busyTimeout[drive]);
|
||||||
|
this.busyTimeout[drive] = setTimeout(() => {
|
||||||
|
this.busy[drive] = false;
|
||||||
|
this.callbacks?.driveLight(drive, false);
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* dumpBlock
|
* dumpBlock
|
||||||
*/
|
*/
|
||||||
|
|
||||||
dumpBlock(drive: number, block: number) {
|
dumpBlock(drive: DriveNumber, block: number) {
|
||||||
let result = '';
|
let result = '';
|
||||||
let b;
|
let b;
|
||||||
let jdx;
|
let jdx;
|
||||||
@ -178,7 +203,7 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
|
|||||||
* getDeviceInfo
|
* getDeviceInfo
|
||||||
*/
|
*/
|
||||||
|
|
||||||
getDeviceInfo(state: CpuState, drive: number) {
|
getDeviceInfo(state: CpuState, drive: DriveNumber) {
|
||||||
if (this.disks[drive]) {
|
if (this.disks[drive]) {
|
||||||
const blocks = this.disks[drive].blocks.length;
|
const blocks = this.disks[drive].blocks.length;
|
||||||
state.x = blocks & 0xff;
|
state.x = blocks & 0xff;
|
||||||
@ -196,7 +221,7 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
|
|||||||
* readBlock
|
* readBlock
|
||||||
*/
|
*/
|
||||||
|
|
||||||
readBlock(state: CpuState, drive: number, block: number, buffer: Address) {
|
readBlock(state: CpuState, drive: DriveNumber, block: number, buffer: Address) {
|
||||||
this.debug(`read drive=${drive}`);
|
this.debug(`read drive=${drive}`);
|
||||||
this.debug(`read buffer=${buffer.toString()}`);
|
this.debug(`read buffer=${buffer.toString()}`);
|
||||||
this.debug(`read block=$${toHex(block)}`);
|
this.debug(`read block=$${toHex(block)}`);
|
||||||
@ -209,6 +234,7 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
|
|||||||
}
|
}
|
||||||
|
|
||||||
// debug('read', '\n' + dumpBlock(drive, block));
|
// debug('read', '\n' + dumpBlock(drive, block));
|
||||||
|
this.driveLight(drive);
|
||||||
|
|
||||||
for (let idx = 0; idx < 512; idx++) {
|
for (let idx = 0; idx < 512; idx++) {
|
||||||
buffer.writeByte(this.disks[drive].blocks[block][idx]);
|
buffer.writeByte(this.disks[drive].blocks[block][idx]);
|
||||||
@ -223,7 +249,7 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
|
|||||||
* writeBlock
|
* writeBlock
|
||||||
*/
|
*/
|
||||||
|
|
||||||
writeBlock(state: CpuState, drive: number, block: number, buffer: Address) {
|
writeBlock(state: CpuState, drive: DriveNumber, block: number, buffer: Address) {
|
||||||
this.debug(`write drive=${drive}`);
|
this.debug(`write drive=${drive}`);
|
||||||
this.debug(`write buffer=${buffer.toString()}`);
|
this.debug(`write buffer=${buffer.toString()}`);
|
||||||
this.debug(`write block=$${toHex(block)}`);
|
this.debug(`write block=$${toHex(block)}`);
|
||||||
@ -243,6 +269,7 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
|
|||||||
}
|
}
|
||||||
|
|
||||||
// debug('write', '\n' + dumpBlock(drive, block));
|
// debug('write', '\n' + dumpBlock(drive, block));
|
||||||
|
this.driveLight(drive);
|
||||||
|
|
||||||
for (let idx = 0; idx < 512; idx++) {
|
for (let idx = 0; idx < 512; idx++) {
|
||||||
this.disks[drive].blocks[block][idx] = buffer.readByte();
|
this.disks[drive].blocks[block][idx] = buffer.readByte();
|
||||||
@ -256,7 +283,7 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
|
|||||||
* formatDevice
|
* formatDevice
|
||||||
*/
|
*/
|
||||||
|
|
||||||
formatDevice(state: CpuState, drive: number) {
|
formatDevice(state: CpuState, drive: DriveNumber) {
|
||||||
if (!this.disks[drive]?.blocks.length) {
|
if (!this.disks[drive]?.blocks.length) {
|
||||||
debug('Drive', drive, 'is empty');
|
debug('Drive', drive, 'is empty');
|
||||||
state.a = DEVICE_OFFLINE;
|
state.a = DEVICE_OFFLINE;
|
||||||
@ -352,7 +379,7 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 3: // FORMAT
|
case 3: // FORMAT
|
||||||
this.formatDevice(state, unit);
|
this.formatDevice(state, drive);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} else if (off === smartOff && this.cpu.getSync()) {
|
} else if (off === smartOff && this.cpu.getSync()) {
|
||||||
@ -375,6 +402,7 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
|
|||||||
|
|
||||||
const parameterCount = cmdListAddr.readByte();
|
const parameterCount = cmdListAddr.readByte();
|
||||||
unit = cmdListAddr.inc(1).readByte();
|
unit = cmdListAddr.inc(1).readByte();
|
||||||
|
const drive = unit ? 2 : 1;
|
||||||
buffer = cmdListAddr.inc(2).readAddress();
|
buffer = cmdListAddr.inc(2).readAddress();
|
||||||
let status;
|
let status;
|
||||||
|
|
||||||
@ -444,16 +472,16 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
|
|||||||
|
|
||||||
case 0x01: // READ BLOCK
|
case 0x01: // READ BLOCK
|
||||||
block = cmdListAddr.inc(4).readWord();
|
block = cmdListAddr.inc(4).readWord();
|
||||||
this.readBlock(state, unit, block, buffer);
|
this.readBlock(state, drive, block, buffer);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 0x02: // WRITE BLOCK
|
case 0x02: // WRITE BLOCK
|
||||||
block = cmdListAddr.inc(4).readWord();
|
block = cmdListAddr.inc(4).readWord();
|
||||||
this.writeBlock(state, unit, block, buffer);
|
this.writeBlock(state, drive, block, buffer);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 0x03: // FORMAT
|
case 0x03: // FORMAT
|
||||||
this.formatDevice(state, unit);
|
this.formatDevice(state, drive);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 0x04: // CONTROL
|
case 0x04: // CONTROL
|
||||||
@ -519,7 +547,7 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
setBinary(drive: number, name: string, fmt: string, rawData: ArrayBuffer) {
|
setBinary(drive: DriveNumber, name: string, fmt: string, rawData: ArrayBuffer) {
|
||||||
const volume = 254;
|
const volume = 254;
|
||||||
const readOnly = false;
|
const readOnly = false;
|
||||||
if (fmt === '2mg') {
|
if (fmt === '2mg') {
|
||||||
@ -534,6 +562,7 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.disks[drive] = createBlockDisk(options);
|
this.disks[drive] = createBlockDisk(options);
|
||||||
|
this.callbacks?.label(drive, name);
|
||||||
|
|
||||||
const prodos = new ProDOSVolume(this.disks[drive]);
|
const prodos = new ProDOSVolume(this.disks[drive]);
|
||||||
dump(prodos);
|
dump(prodos);
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
import { h } from 'preact';
|
import { h } from 'preact';
|
||||||
import cs from 'classnames';
|
import cs from 'classnames';
|
||||||
import {useEffect, useRef, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
import { Apple2 as Apple2Impl } from '../apple2';
|
import { Apple2 as Apple2Impl } from '../apple2';
|
||||||
import Apple2IO from '../apple2io';
|
import Apple2IO from '../apple2io';
|
||||||
import CPU6502 from '../cpu6502';
|
import CPU6502 from '../cpu6502';
|
||||||
import { ControlStrip } from './ControlStrip';
|
import { ControlStrip } from './ControlStrip';
|
||||||
|
import { ErrorModal } from './ErrorModal';
|
||||||
import { Inset } from './Inset';
|
import { Inset } from './Inset';
|
||||||
import { Keyboard } from './Keyboard';
|
import { Keyboard } from './Keyboard';
|
||||||
import { Mouse } from './Mouse';
|
import { Mouse } from './Mouse';
|
||||||
@ -12,7 +13,7 @@ import { Screen } from './Screen';
|
|||||||
import { Drives } from './Drives';
|
import { Drives } from './Drives';
|
||||||
import { Slinky } from './Slinky';
|
import { Slinky } from './Slinky';
|
||||||
import { ThunderClock } from './ThunderClock';
|
import { ThunderClock } from './ThunderClock';
|
||||||
import { ErrorModal } from './ErrorModal';
|
import { noAwait, Ready } from './util/promises';
|
||||||
|
|
||||||
import styles from './css/Apple2.module.css';
|
import styles from './css/Apple2.module.css';
|
||||||
|
|
||||||
@ -38,12 +39,13 @@ export interface Apple2Props {
|
|||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
export const Apple2 = (props: Apple2Props) => {
|
export const Apple2 = (props: Apple2Props) => {
|
||||||
const { e, sectors } = props;
|
const { e, enhanced, sectors } = props;
|
||||||
const screen = useRef<HTMLCanvasElement>(null);
|
const screen = useRef<HTMLCanvasElement>(null);
|
||||||
const [apple2, setApple2] = useState<Apple2Impl>();
|
const [apple2, setApple2] = useState<Apple2Impl>();
|
||||||
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 drivesReady = useMemo(() => new Ready(setError), []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (screen.current) {
|
if (screen.current) {
|
||||||
@ -53,26 +55,30 @@ export const Apple2 = (props: Apple2Props) => {
|
|||||||
...props,
|
...props,
|
||||||
};
|
};
|
||||||
const apple2 = new Apple2Impl(options);
|
const apple2 = new Apple2Impl(options);
|
||||||
apple2.ready.then(() => {
|
noAwait((async () => {
|
||||||
setApple2(apple2);
|
try {
|
||||||
const io = apple2.getIO();
|
await apple2.ready;
|
||||||
const cpu = apple2.getCPU();
|
setApple2(apple2);
|
||||||
setIO(io);
|
setIO(apple2.getIO());
|
||||||
setCPU(cpu);
|
setCPU(apple2.getCPU());
|
||||||
apple2.reset();
|
await drivesReady.ready;
|
||||||
apple2.run();
|
apple2.reset();
|
||||||
}).catch((e) => setError(e));
|
apple2.run();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e);
|
||||||
|
}
|
||||||
|
}))();
|
||||||
}
|
}
|
||||||
}, [props]);
|
}, [props, drivesReady]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cs(styles.outer, { apple2e: e })}>
|
<div className={cs(styles.outer, { apple2e: e })}>
|
||||||
<Screen screen={screen} />
|
<Screen screen={screen} />
|
||||||
|
<Slinky io={io} slot={2} />
|
||||||
<Mouse cpu={cpu} screen={screen} io={io} slot={4} />
|
<Mouse cpu={cpu} screen={screen} io={io} slot={4} />
|
||||||
<Slinky io={io} slot={4} />
|
|
||||||
<ThunderClock io={io} slot={5} />
|
<ThunderClock io={io} slot={5} />
|
||||||
<Inset>
|
<Inset>
|
||||||
<Drives io={io} sectors={sectors} />
|
<Drives cpu={cpu} io={io} sectors={sectors} enhanced={enhanced} ready={drivesReady} />
|
||||||
</Inset>
|
</Inset>
|
||||||
<ControlStrip apple2={apple2} e={e} />
|
<ControlStrip apple2={apple2} e={e} />
|
||||||
<Inset>
|
<Inset>
|
||||||
|
74
js/components/BlockDisk.tsx
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { useCallback, useState } from 'preact/hooks';
|
||||||
|
import cs from 'classnames';
|
||||||
|
import SmartPort from '../cards/smartport';
|
||||||
|
import { BlockFileModal } from './BlockFileModal';
|
||||||
|
import { ErrorModal } from './ErrorModal';
|
||||||
|
|
||||||
|
import styles from './css/BlockDisk.module.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage structure for drive state returned via callbacks.
|
||||||
|
*/
|
||||||
|
export interface BlockDiskData {
|
||||||
|
number: 1 | 2;
|
||||||
|
on: boolean;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for BlockDisk.
|
||||||
|
*/
|
||||||
|
export interface BlockDiskProps extends BlockDiskData {
|
||||||
|
smartPort: SmartPort | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BlockDisk component
|
||||||
|
*
|
||||||
|
* Includes drive light, disk name and side, and UI for loading disks.
|
||||||
|
*
|
||||||
|
* @param smartPort SmartPort object
|
||||||
|
* @param number Drive 1 or 2
|
||||||
|
* @param on Active state
|
||||||
|
* @param name Disk name identifier
|
||||||
|
* @param side Disk side identifier
|
||||||
|
* @returns BlockDisk component
|
||||||
|
*/
|
||||||
|
export const BlockDisk = ({ smartPort, number, on, name }: BlockDiskProps) => {
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [error, setError] = useState<unknown>();
|
||||||
|
|
||||||
|
const doClose = useCallback(() => {
|
||||||
|
setModalOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onOpenModal = useCallback(() => {
|
||||||
|
setModalOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.disk}>
|
||||||
|
<ErrorModal error={error} setError={setError} />
|
||||||
|
<BlockFileModal
|
||||||
|
smartPort={smartPort}
|
||||||
|
number={number}
|
||||||
|
onClose={doClose}
|
||||||
|
isOpen={modalOpen}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
id={`disk${number}`}
|
||||||
|
className={cs(styles.diskLight, { [styles.on]: on })}
|
||||||
|
/>
|
||||||
|
<button title="Load Disk" onClick={onOpenModal}>
|
||||||
|
<i className="fas fa-folder-open" />
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
id={`disk-label${number}`}
|
||||||
|
className={styles.diskLabel}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
77
js/components/BlockFileModal.tsx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import { h, Fragment } from 'preact';
|
||||||
|
import { useCallback, useState } from 'preact/hooks';
|
||||||
|
import { DriveNumber, BLOCK_FORMATS } from '../formats/types';
|
||||||
|
import { ErrorModal } from './ErrorModal';
|
||||||
|
import { FileChooser } from './FileChooser';
|
||||||
|
import { Modal, ModalContent, ModalFooter } from './Modal';
|
||||||
|
import { loadLocalBlockFile, getHashParts, setHashParts } from './util/files';
|
||||||
|
import SmartPort from 'js/cards/smartport';
|
||||||
|
import { useHash } from './hooks/useHash';
|
||||||
|
import { noAwait } from './util/promises';
|
||||||
|
|
||||||
|
import styles from './css/BlockFileModal.module.css';
|
||||||
|
|
||||||
|
const DISK_TYPES: FilePickerAcceptType[] = [
|
||||||
|
{
|
||||||
|
description: 'Disk Images',
|
||||||
|
accept: { 'application/octet-stream': BLOCK_FORMATS.map(x => '.' + x) },
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
interface BlockFileModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
smartPort: SmartPort | undefined;
|
||||||
|
number: DriveNumber;
|
||||||
|
onClose: (closeBox?: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BlockFileModal = ({ smartPort, number, onClose, isOpen } : BlockFileModalProps) => {
|
||||||
|
const [handles, setHandles] = useState<FileSystemFileHandle[]>();
|
||||||
|
const [busy, setBusy] = useState<boolean>(false);
|
||||||
|
const [empty, setEmpty] = useState<boolean>(true);
|
||||||
|
const [error, setError] = useState<unknown>();
|
||||||
|
const hash = useHash();
|
||||||
|
|
||||||
|
const doCancel = useCallback(() => onClose(true), [onClose]);
|
||||||
|
|
||||||
|
const doOpen = useCallback(async () => {
|
||||||
|
const hashParts = getHashParts(hash);
|
||||||
|
|
||||||
|
if (smartPort && handles?.length === 1) {
|
||||||
|
hashParts[number] = '';
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await loadLocalBlockFile(smartPort, number, await handles[0].getFile());
|
||||||
|
} catch (error) {
|
||||||
|
setError(error);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setHashParts(hashParts);
|
||||||
|
}, [handles, hash, smartPort, number, onClose]);
|
||||||
|
|
||||||
|
const onChange = useCallback((handles: FileSystemFileHandle[]) => {
|
||||||
|
setEmpty(handles.length === 0);
|
||||||
|
setHandles(handles);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal title="Open File" isOpen={isOpen} onClose={onClose}>
|
||||||
|
<ModalContent>
|
||||||
|
<div className={styles.modalContent}>
|
||||||
|
<FileChooser onChange={onChange} accept={DISK_TYPES} />
|
||||||
|
</div>
|
||||||
|
</ModalContent>
|
||||||
|
<ModalFooter>
|
||||||
|
<button onClick={doCancel}>Cancel</button>
|
||||||
|
<button onClick={noAwait(doOpen)} disabled={busy || empty}>Open</button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
||||||
|
<ErrorModal error={error} setError={setError} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -1,11 +1,9 @@
|
|||||||
import { h } from 'preact';
|
import { h } from 'preact';
|
||||||
import { useCallback, useEffect, useState } from 'preact/hooks';
|
import { useCallback, useState } from 'preact/hooks';
|
||||||
import cs from 'classnames';
|
import cs from 'classnames';
|
||||||
import Disk2 from '../cards/disk2';
|
import Disk2 from '../cards/disk2';
|
||||||
import { FileModal } from './FileModal';
|
|
||||||
import { loadJSON, loadHttpFile, getHashParts } from './util/files';
|
|
||||||
import { ErrorModal } from './ErrorModal';
|
import { ErrorModal } from './ErrorModal';
|
||||||
import { useHash } from './hooks/useHash';
|
import { FileModal } from './FileModal';
|
||||||
|
|
||||||
import styles from './css/DiskII.module.css';
|
import styles from './css/DiskII.module.css';
|
||||||
|
|
||||||
@ -30,7 +28,6 @@ export interface DiskIIProps extends DiskIIData {
|
|||||||
* Disk II component
|
* Disk II component
|
||||||
*
|
*
|
||||||
* Includes drive light, disk name and side, and UI for loading disks.
|
* Includes drive light, disk name and side, and UI for loading disks.
|
||||||
* Handles initial loading of disks specified in the hash.
|
|
||||||
*
|
*
|
||||||
* @param disk2 Disk2 object
|
* @param disk2 Disk2 object
|
||||||
* @param number Drive 1 or 2
|
* @param number Drive 1 or 2
|
||||||
@ -43,28 +40,6 @@ export const DiskII = ({ disk2, number, on, name, side }: DiskIIProps) => {
|
|||||||
const label = side ? `${name} - ${side}` : name;
|
const label = side ? `${name} - ${side}` : name;
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [error, setError] = useState<unknown>();
|
const [error, setError] = useState<unknown>();
|
||||||
const [currentHash, setCurrentHash] = useState<string>();
|
|
||||||
|
|
||||||
const hash = useHash();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const hashParts = getHashParts(hash);
|
|
||||||
const newHash = hashParts[number];
|
|
||||||
if (disk2 && newHash) {
|
|
||||||
const hashPart = decodeURIComponent(newHash);
|
|
||||||
if (hashPart !== currentHash) {
|
|
||||||
if (hashPart.match(/^https?:/)) {
|
|
||||||
loadHttpFile(disk2, number, hashPart)
|
|
||||||
.catch((e) => setError(e));
|
|
||||||
} else {
|
|
||||||
const filename = `/json/disks/${hashPart}.json`;
|
|
||||||
loadJSON(disk2, number, filename)
|
|
||||||
.catch((e) => setError(e));
|
|
||||||
}
|
|
||||||
setCurrentHash(hashPart);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [currentHash, disk2, hash, number]);
|
|
||||||
|
|
||||||
const doClose = useCallback(() => {
|
const doClose = useCallback(() => {
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
|
@ -1,26 +1,53 @@
|
|||||||
import { h, Fragment } from 'preact';
|
import { h } from 'preact';
|
||||||
import {useEffect, useState } from 'preact/hooks';
|
import { useCallback, useEffect, 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';
|
||||||
|
import SmartPort from 'js/cards/smartport';
|
||||||
|
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 { useHash } from './hooks/useHash';
|
||||||
|
import { DriveNumber } from 'js/formats/types';
|
||||||
|
import { Ready } from './util/promises';
|
||||||
|
|
||||||
|
import styles from './css/Drives.module.css';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interface for Drives component.
|
* Interface for Drives component.
|
||||||
*/
|
*/
|
||||||
export interface DrivesProps {
|
export interface DrivesProps {
|
||||||
|
cpu: CPU6502 | undefined;
|
||||||
io: Apple2IO | undefined;
|
io: Apple2IO | undefined;
|
||||||
|
enhanced: boolean;
|
||||||
sectors: number;
|
sectors: number;
|
||||||
|
ready: Ready;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drive interface component. Presents the interface to load disks.
|
* Drive interface component. Presents the interface to load disks.
|
||||||
* Provides the callback to the Disk2 object to update the DiskII
|
* Provides the callback to the Disk2 and SmartPort objects to update
|
||||||
* components.
|
* the DiskII and BlockDisk components.
|
||||||
|
* Handles initial loading of disks specified in the hash.
|
||||||
*
|
*
|
||||||
|
* @cpu CPU object
|
||||||
* @param io Apple I/O object
|
* @param io Apple I/O object
|
||||||
|
* @param sectors 13 or 16 sector rom mode
|
||||||
|
* @enhanced Whether to create a SmartPort ROM device
|
||||||
|
* @ready Signal disk availability
|
||||||
* @returns Drives component
|
* @returns Drives component
|
||||||
*/
|
*/
|
||||||
export const Drives = ({ io, sectors }: DrivesProps) => {
|
export const Drives = ({ cpu, io, sectors, enhanced, ready }: DrivesProps) => {
|
||||||
|
const [current, setCurrent] = useState(0);
|
||||||
|
const [error, setError] = useState<unknown>();
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const onProgress = useCallback((current: number, total: number) => {
|
||||||
|
setCurrent(current);
|
||||||
|
setTotal(total);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [disk2, setDisk2] = useState<Disk2>();
|
const [disk2, setDisk2] = useState<Disk2>();
|
||||||
const [data1, setData1] = useState<DiskIIData>({
|
const [data1, setData1] = useState<DiskIIData>({
|
||||||
on: false,
|
on: false,
|
||||||
@ -32,9 +59,54 @@ export const Drives = ({ io, sectors }: DrivesProps) => {
|
|||||||
number: 2,
|
number: 2,
|
||||||
name: 'Disk 2',
|
name: 'Disk 2',
|
||||||
});
|
});
|
||||||
|
const [smartData1, setSmartData1] = useState<DiskIIData>({
|
||||||
|
on: false,
|
||||||
|
number: 1,
|
||||||
|
name: 'HD 1'
|
||||||
|
});
|
||||||
|
const [smartData2, setSmartData2] = useState<DiskIIData>({
|
||||||
|
on: false,
|
||||||
|
number: 2,
|
||||||
|
name: 'HD 2'
|
||||||
|
});
|
||||||
|
|
||||||
|
const [smartPort, setSmartPort] = useState<SmartPort>();
|
||||||
|
|
||||||
|
const hash = useHash();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (smartPort && disk2) {
|
||||||
|
const hashParts = getHashParts(hash);
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const url = `/json/disks/${hashPart}.json`;
|
||||||
|
loadJSON(disk2, drive, url).catch((e) => setError(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!loading) {
|
||||||
|
ready.onReady();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [hash, onProgress, disk2, ready, smartPort]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const setData = [setData1, setData2];
|
const setData = [setData1, setData2];
|
||||||
|
const setSmartData = [setSmartData1, setSmartData2];
|
||||||
const callbacks: Callbacks = {
|
const callbacks: Callbacks = {
|
||||||
driveLight: (drive, on) => {
|
driveLight: (drive, on) => {
|
||||||
setData[drive - 1]?.(data => ({...data, on }));
|
setData[drive - 1]?.(data => ({...data, on }));
|
||||||
@ -51,17 +123,42 @@ export const Drives = ({ io, sectors }: DrivesProps) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (io) {
|
const smartPortCallbacks: Callbacks = {
|
||||||
|
driveLight: (drive, on) => {
|
||||||
|
setSmartData[drive - 1]?.(data => ({...data, on }));
|
||||||
|
},
|
||||||
|
label: (drive, name, side) => {
|
||||||
|
setSmartData[drive - 1]?.(data => ({
|
||||||
|
...data,
|
||||||
|
name: name ?? `HD ${drive}`,
|
||||||
|
side,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
dirty: () => {/* Unused */}
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
setDisk2(disk2);
|
||||||
|
const smartPort = new SmartPort(cpu, smartPortCallbacks, { block: !enhanced });
|
||||||
|
io.setSlot(7, smartPort);
|
||||||
|
setSmartPort(smartPort);
|
||||||
}
|
}
|
||||||
}, [io, sectors]);
|
}, [cpu, enhanced, io, sectors]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className={styles.drives}>
|
||||||
<DiskII disk2={disk2} {...data1} />
|
<ProgressModal current={current} total={total} title="Loading..." />
|
||||||
<DiskII disk2={disk2} {...data2} />
|
<ErrorModal error={error} setError={setError} />
|
||||||
</>
|
<div className={styles.driveBay}>
|
||||||
|
<DiskII disk2={disk2} {...data1} />
|
||||||
|
<DiskII disk2={disk2} {...data2} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.driveBay}>
|
||||||
|
<BlockDisk smartPort={smartPort} {...smartData1} />
|
||||||
|
<BlockDisk smartPort={smartPort} {...smartData2} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -2,7 +2,7 @@ import { h, Fragment, JSX } from 'preact';
|
|||||||
import { useCallback, useState } from 'preact/hooks';
|
import { useCallback, useState } from 'preact/hooks';
|
||||||
import { DiskDescriptor, DriveNumber, NibbleFormat, NIBBLE_FORMATS } from '../formats/types';
|
import { DiskDescriptor, DriveNumber, NibbleFormat, NIBBLE_FORMATS } from '../formats/types';
|
||||||
import { Modal, ModalContent, ModalFooter } from './Modal';
|
import { Modal, ModalContent, ModalFooter } from './Modal';
|
||||||
import { loadLocalFile, loadJSON, getHashParts, setHashParts } from './util/files';
|
import { loadLocalNibbleFile, loadJSON, getHashParts, setHashParts } from './util/files';
|
||||||
import DiskII from '../cards/disk2';
|
import DiskII from '../cards/disk2';
|
||||||
import { ErrorModal } from './ErrorModal';
|
import { ErrorModal } from './ErrorModal';
|
||||||
|
|
||||||
@ -66,7 +66,7 @@ export const FileModal = ({ disk2, number, onClose, isOpen }: FileModalProps) =>
|
|||||||
try {
|
try {
|
||||||
if (disk2 && handles?.length === 1) {
|
if (disk2 && handles?.length === 1) {
|
||||||
hashParts[number] = '';
|
hashParts[number] = '';
|
||||||
await loadLocalFile(disk2, number, await handles[0].getFile());
|
await loadLocalNibbleFile(disk2, number, await handles[0].getFile());
|
||||||
}
|
}
|
||||||
if (disk2 && filename) {
|
if (disk2 && filename) {
|
||||||
const name = filename.match(/\/([^/]+).json$/) || ['', ''];
|
const name = filename.match(/\/([^/]+).json$/) || ['', ''];
|
||||||
|
29
js/components/ProgressModal.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { Modal, ModalContent } from './Modal';
|
||||||
|
|
||||||
|
import styles from './css/ProgressModal.module.css';
|
||||||
|
|
||||||
|
export interface ErrorProps {
|
||||||
|
title: string;
|
||||||
|
current: number | undefined;
|
||||||
|
total: number | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProgressModal = ({ title, current, total } : ErrorProps) => {
|
||||||
|
if (current && total) {
|
||||||
|
return (
|
||||||
|
<Modal title={title} isOpen={true}>
|
||||||
|
<ModalContent>
|
||||||
|
<div className={styles.progressContainer}>
|
||||||
|
<div
|
||||||
|
className={styles.progressBar}
|
||||||
|
style={{ width: Math.floor(320 * (current / total)) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
38
js/components/css/BlockDisk.module.css
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
.disk {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diskLight {
|
||||||
|
margin: 5px;
|
||||||
|
background-image: url(../../../css/green-off-16.png);
|
||||||
|
background-size: 16px 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diskLight.on {
|
||||||
|
background-image: url(../../../css/green-on-16.png);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diskLabel {
|
||||||
|
color: #000;
|
||||||
|
font-family: sans-serif;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-right: 0.5em;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (min-resolution: 1.25dppx) {
|
||||||
|
.diskLight {
|
||||||
|
background-image: url(../../../css/green-off-32.png);
|
||||||
|
}
|
||||||
|
.diskLight.on {
|
||||||
|
background-image: url(../../../css/green-on-32.png);
|
||||||
|
}
|
||||||
|
}
|
3
js/components/css/BlockFileModal.module.css
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
.modalContent {
|
||||||
|
width: 320px;
|
||||||
|
}
|
@ -2,12 +2,12 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
max-width: 50%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.diskLight {
|
.diskLight {
|
||||||
margin: 5px;
|
margin: 5px;
|
||||||
background-image: url(../../../css/red-off-16.png);
|
background-image: url(../../../css/red-off-16.png);
|
||||||
|
background-size: 16px 16px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
@ -27,3 +27,12 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media only screen and (min-resolution: 1.25dppx) {
|
||||||
|
.diskLight {
|
||||||
|
background-image: url(../../../css/red-off-32.png);
|
||||||
|
}
|
||||||
|
.diskLight.on {
|
||||||
|
background-image: url(../../../css/red-on-32.png);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
10
js/components/css/Drives.module.css
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
.drives {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.driveBay {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1 1 50%;
|
||||||
|
}
|
@ -11,7 +11,6 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.inset button {
|
.inset button {
|
||||||
min-width: 36px;
|
min-width: 36px;
|
||||||
margin: 0 2px;
|
margin: 0 2px;
|
||||||
|
10
js/components/css/ProgressModal.module.css
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
.progressContainer {
|
||||||
|
width: 320px;
|
||||||
|
height: 20px;
|
||||||
|
background: #000
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressBar {
|
||||||
|
height: 20px;
|
||||||
|
background: #0f0;
|
||||||
|
}
|
@ -1,11 +1,19 @@
|
|||||||
import { includes } from 'js/types';
|
import { includes } from 'js/types';
|
||||||
import { initGamepad } from 'js/ui/gamepad';
|
import { initGamepad } from 'js/ui/gamepad';
|
||||||
import {
|
import {
|
||||||
|
BlockFormat,
|
||||||
|
BLOCK_FORMATS,
|
||||||
|
DISK_FORMATS,
|
||||||
DriveNumber,
|
DriveNumber,
|
||||||
JSONDisk,
|
JSONDisk,
|
||||||
NIBBLE_FORMATS
|
MassStorage,
|
||||||
|
NibbleFormat,
|
||||||
|
NIBBLE_FORMATS,
|
||||||
} from 'js/formats/types';
|
} from 'js/formats/types';
|
||||||
import DiskII from 'js/cards/disk2';
|
import Disk2 from 'js/cards/disk2';
|
||||||
|
import SmartPort from 'js/cards/smartport';
|
||||||
|
|
||||||
|
type ProgressCallback = (current: number, total: number) => void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Routine to split a legacy hash into parts for disk loading
|
* Routine to split a legacy hash into parts for disk loading
|
||||||
@ -26,17 +34,19 @@ export const setHashParts = (parts: string[]) => {
|
|||||||
window.location.hash = `#${parts[1]}` + (parts[2] ? `|${parts[2]}` : '');
|
window.location.hash = `#${parts[1]}` + (parts[2] ? `|${parts[2]}` : '');
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
export const getNameAndExtension = (url: string) => {
|
||||||
* Local file loading routine. Allows a File object from a file
|
const urlParts = url.split('/');
|
||||||
* selection form element to be loaded.
|
const file = urlParts.pop() || url;
|
||||||
*
|
const fileParts = file.split('.');
|
||||||
* @param disk2 Disk2 object
|
const ext = fileParts.pop()?.toLowerCase() || '[none]';
|
||||||
* @param number Drive number
|
const name = decodeURIComponent(fileParts.join('.'));
|
||||||
* @param file Browser File object to load
|
|
||||||
* @returns true if successful
|
return { name, ext };
|
||||||
*/
|
};
|
||||||
|
|
||||||
export const loadLocalFile = (
|
export const loadLocalFile = (
|
||||||
disk2: DiskII,
|
storage: MassStorage<NibbleFormat|BlockFormat>,
|
||||||
|
formats: typeof NIBBLE_FORMATS | typeof BLOCK_FORMATS,
|
||||||
number: DriveNumber,
|
number: DriveNumber,
|
||||||
file: File,
|
file: File,
|
||||||
) => {
|
) => {
|
||||||
@ -48,16 +58,12 @@ export const loadLocalFile = (
|
|||||||
const ext = parts.pop()?.toLowerCase() || '[none]';
|
const ext = parts.pop()?.toLowerCase() || '[none]';
|
||||||
const name = parts.join('.');
|
const name = parts.join('.');
|
||||||
|
|
||||||
if (includes(NIBBLE_FORMATS, ext)) {
|
if (includes(formats, ext)) {
|
||||||
if (result.byteLength >= 800 * 1024) {
|
initGamepad();
|
||||||
reject(`Unable to load ${name}`);
|
if (storage.setBinary(number, name, ext, result)) {
|
||||||
|
resolve(true);
|
||||||
} else {
|
} else {
|
||||||
initGamepad();
|
reject(`Unable to load ${name}`);
|
||||||
if (disk2.setBinary(number, name, ext, result)) {
|
|
||||||
resolve(true);
|
|
||||||
} else {
|
|
||||||
reject(`Unable to load ${name}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
reject(`Extension "${ext}" not recognized.`);
|
reject(`Extension "${ext}" not recognized.`);
|
||||||
@ -67,6 +73,32 @@ export const loadLocalFile = (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Local file loading routine. Allows a File object from a file
|
||||||
|
* selection form element to be loaded.
|
||||||
|
*
|
||||||
|
* @param smartPort SmartPort object
|
||||||
|
* @param number Drive number
|
||||||
|
* @param file Browser File object to load
|
||||||
|
* @returns true if successful
|
||||||
|
*/
|
||||||
|
export const loadLocalBlockFile = (smartPort: SmartPort, number: DriveNumber, file: File) => {
|
||||||
|
return loadLocalFile(smartPort, BLOCK_FORMATS, number, file);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Local file loading routine. Allows a File object from a file
|
||||||
|
* selection form element to be loaded.
|
||||||
|
*
|
||||||
|
* @param disk2 Disk2 object
|
||||||
|
* @param number Drive number
|
||||||
|
* @param file Browser File object to load
|
||||||
|
* @returns true if successful
|
||||||
|
*/
|
||||||
|
export const loadLocalNibbleFile = (disk2: Disk2, number: DriveNumber, file: File) => {
|
||||||
|
return loadLocalFile(disk2, NIBBLE_FORMATS, number, file);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* JSON loading routine, loads a JSON file at the given URL. Requires
|
* JSON loading routine, loads a JSON file at the given URL. Requires
|
||||||
* proper cross domain loading headers if the URL is not on the same server
|
* proper cross domain loading headers if the URL is not on the same server
|
||||||
@ -77,37 +109,27 @@ export const loadLocalFile = (
|
|||||||
* @param url URL, relative or absolute to JSON file
|
* @param url URL, relative or absolute to JSON file
|
||||||
* @returns true if successful
|
* @returns true if successful
|
||||||
*/
|
*/
|
||||||
export const loadJSON = async (disk2: DiskII, number: DriveNumber, url: string) => {
|
export const loadJSON = async (
|
||||||
|
disk2: Disk2,
|
||||||
|
number: DriveNumber,
|
||||||
|
url: string,
|
||||||
|
) => {
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Error loading: ${response.statusText}`);
|
throw new Error(`Error loading: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
const data = await response.json() as JSONDisk;
|
const data = await response.json() as JSONDisk;
|
||||||
if (!includes(NIBBLE_FORMATS, data.type)) {
|
if (!includes(NIBBLE_FORMATS, data.type)) {
|
||||||
throw new Error(`Type ${data.type} not recognized.`);
|
throw new Error(`Type "${data.type}" not recognized.`);
|
||||||
}
|
}
|
||||||
disk2.setDisk(number, data);
|
disk2.setDisk(number, data);
|
||||||
initGamepad(data.gamepad);
|
initGamepad(data.gamepad);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* HTTP loading routine, loads a file at the given URL. Requires
|
|
||||||
* proper cross domain loading headers if the URL is not on the same server
|
|
||||||
* as the emulator. Only supports nibble based formats at the moment.
|
|
||||||
*
|
|
||||||
* @param disk2 Disk2 object
|
|
||||||
* @param number Drive number
|
|
||||||
* @param url URL, relative or absolute to JSON file
|
|
||||||
* @returns true if successful
|
|
||||||
*/
|
|
||||||
export const loadHttpFile = async (
|
export const loadHttpFile = async (
|
||||||
disk2: DiskII,
|
|
||||||
number: DriveNumber,
|
|
||||||
url: string,
|
url: string,
|
||||||
) => {
|
onProgress?: ProgressCallback
|
||||||
if (url.endsWith('.json')) {
|
): Promise<ArrayBuffer> => {
|
||||||
return loadJSON(disk2, number, url);
|
|
||||||
}
|
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Error loading: ${response.statusText}`);
|
throw new Error(`Error loading: ${response.statusText}`);
|
||||||
@ -116,13 +138,16 @@ export const loadHttpFile = async (
|
|||||||
throw new Error('Error loading: no body');
|
throw new Error('Error loading: no body');
|
||||||
}
|
}
|
||||||
const reader = response.body.getReader();
|
const reader = response.body.getReader();
|
||||||
|
const contentLength = parseInt(response.headers.get('content-length') || '0', 10);
|
||||||
let received = 0;
|
let received = 0;
|
||||||
const chunks: Uint8Array[] = [];
|
const chunks: Uint8Array[] = [];
|
||||||
|
|
||||||
let result = await reader.read();
|
let result = await reader.read();
|
||||||
|
onProgress?.(1, contentLength);
|
||||||
while (!result.done) {
|
while (!result.done) {
|
||||||
chunks.push(result.value);
|
chunks.push(result.value);
|
||||||
received += result.value.length;
|
received += result.value.length;
|
||||||
|
onProgress?.(received, contentLength);
|
||||||
result = await reader.read();
|
result = await reader.read();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -133,14 +158,87 @@ export const loadHttpFile = async (
|
|||||||
offset += chunk.length;
|
offset += chunk.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
const urlParts = url.split('/');
|
return data.buffer;
|
||||||
const file = urlParts.pop() || url;
|
};
|
||||||
const fileParts = file.split('.');
|
|
||||||
const ext = fileParts.pop()?.toLowerCase() || '[none]';
|
/**
|
||||||
const name = decodeURIComponent(fileParts.join('.'));
|
* HTTP loading routine, loads a file at the given URL. Requires
|
||||||
|
* proper cross domain loading headers if the URL is not on the same server
|
||||||
|
* as the emulator.
|
||||||
|
*
|
||||||
|
* @param smartPort SmartPort object
|
||||||
|
* @param number Drive number
|
||||||
|
* @param url URL, relative or absolute to JSON file
|
||||||
|
* @returns true if successful
|
||||||
|
*/
|
||||||
|
export const loadHttpBlockFile = async (
|
||||||
|
smartPort: SmartPort,
|
||||||
|
number: DriveNumber,
|
||||||
|
url: string,
|
||||||
|
onProgress?: ProgressCallback
|
||||||
|
): Promise<boolean> => {
|
||||||
|
const { name, ext } = getNameAndExtension(url);
|
||||||
|
if (!includes(BLOCK_FORMATS, ext)) {
|
||||||
|
throw new Error(`Extension "${ext}" not recognized.`);
|
||||||
|
}
|
||||||
|
const data = await loadHttpFile(url, onProgress);
|
||||||
|
smartPort.setBinary(number, name, ext, data);
|
||||||
|
initGamepad();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP loading routine, loads a file at the given URL. Requires
|
||||||
|
* proper cross domain loading headers if the URL is not on the same server
|
||||||
|
* as the emulator.
|
||||||
|
*
|
||||||
|
* @param disk2 Disk2 object
|
||||||
|
* @param number Drive number
|
||||||
|
* @param url URL, relative or absolute to JSON file
|
||||||
|
* @returns true if successful
|
||||||
|
*/
|
||||||
|
export const loadHttpNibbleFile = async (
|
||||||
|
disk2: Disk2,
|
||||||
|
number: DriveNumber,
|
||||||
|
url: string,
|
||||||
|
onProgress?: ProgressCallback
|
||||||
|
) => {
|
||||||
|
if (url.endsWith('.json')) {
|
||||||
|
return loadJSON(disk2, number, url);
|
||||||
|
}
|
||||||
|
const { name, ext } = getNameAndExtension(url);
|
||||||
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);
|
||||||
disk2.setBinary(number, name, ext, data);
|
disk2.setBinary(number, name, ext, data);
|
||||||
initGamepad();
|
initGamepad();
|
||||||
|
return loadHttpFile(url, onProgress);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loadHttpUnknownFile = async (
|
||||||
|
disk2: Disk2,
|
||||||
|
smartPort: SmartPort,
|
||||||
|
number: DriveNumber,
|
||||||
|
url: string,
|
||||||
|
onProgress?: ProgressCallback,
|
||||||
|
) => {
|
||||||
|
const data = await loadHttpFile(url, 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);
|
||||||
|
} 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}"`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(`Extension "${ext}" not recognized.`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
@ -161,7 +161,6 @@ const uiKitMap = {
|
|||||||
'UIKeyInputEscape': 0x1B
|
'UIKeyInputEscape': 0x1B
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|
||||||
export const isUiKitKey = (k: string): k is KnownKeys<typeof uiKitMap> => {
|
export const isUiKitKey = (k: string): k is KnownKeys<typeof uiKitMap> => {
|
||||||
return k in uiKitMap;
|
return k in uiKitMap;
|
||||||
};
|
};
|
||||||
|
@ -10,3 +10,22 @@ export type NoAwait<F extends (...args: unknown[]) => Promise<unknown>> =
|
|||||||
export function noAwait<F extends (...args: unknown[]) => Promise<unknown>>(f: F): NoAwait<F> {
|
export function noAwait<F extends (...args: unknown[]) => Promise<unknown>>(f: F): NoAwait<F> {
|
||||||
return f as NoAwait<F>;
|
return f as NoAwait<F>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility class that allows a promise to be passed to a
|
||||||
|
* service to be resolved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class Ready {
|
||||||
|
onError: (value?: unknown) => void;
|
||||||
|
onReady: (value?: unknown) => void;
|
||||||
|
|
||||||
|
ready: Promise<unknown>;
|
||||||
|
|
||||||
|
constructor(private errorHandler = console.error) {
|
||||||
|
this.ready = new Promise((resolve, reject) => {
|
||||||
|
this.onReady = resolve;
|
||||||
|
this.onError = reject;
|
||||||
|
}).catch(this.errorHandler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -215,6 +215,6 @@ export type FormatWorkerResponse =
|
|||||||
/**
|
/**
|
||||||
* Block device common interface
|
* Block device common interface
|
||||||
*/
|
*/
|
||||||
export interface MassStorage {
|
export interface MassStorage<T> {
|
||||||
setBinary(drive: number, name: string, ext: BlockFormat, data: ArrayBuffer): boolean;
|
setBinary(drive: number, name: string, ext: T, data: ArrayBuffer): boolean;
|
||||||
}
|
}
|
||||||
|
@ -74,7 +74,7 @@ apple2.ready.then(() => {
|
|||||||
const slinky = new RAMFactor(1024 * 1024);
|
const slinky = new RAMFactor(1024 * 1024);
|
||||||
const disk2 = new DiskII(io, driveLights, sectors);
|
const disk2 = new DiskII(io, driveLights, sectors);
|
||||||
const clock = new Thunderclock();
|
const clock = new Thunderclock();
|
||||||
const smartport = new SmartPort(cpu, { block: true });
|
const smartport = new SmartPort(cpu, null, { block: true });
|
||||||
|
|
||||||
io.setSlot(0, lc);
|
io.setSlot(0, lc);
|
||||||
io.setSlot(1, parallel);
|
io.setSlot(1, parallel);
|
||||||
|
@ -63,7 +63,7 @@ apple2.ready.then(() => {
|
|||||||
const slinky = new RAMFactor(1024 * 1024);
|
const slinky = new RAMFactor(1024 * 1024);
|
||||||
const disk2 = new DiskII(io, driveLights);
|
const disk2 = new DiskII(io, driveLights);
|
||||||
const clock = new Thunderclock();
|
const clock = new Thunderclock();
|
||||||
const smartport = new SmartPort(cpu, { block: !enhanced });
|
const smartport = new SmartPort(cpu, null, { block: !enhanced });
|
||||||
const mouse = new Mouse(cpu, mouseUI);
|
const mouse = new Mouse(cpu, mouseUI);
|
||||||
|
|
||||||
io.setSlot(1, parallel);
|
io.setSlot(1, parallel);
|
||||||
|
@ -13,7 +13,8 @@ import {
|
|||||||
MassStorage,
|
MassStorage,
|
||||||
NIBBLE_FORMATS,
|
NIBBLE_FORMATS,
|
||||||
JSONBinaryImage,
|
JSONBinaryImage,
|
||||||
JSONDisk
|
JSONDisk,
|
||||||
|
BlockFormat
|
||||||
} from '../formats/types';
|
} from '../formats/types';
|
||||||
import { initGamepad } from './gamepad';
|
import { initGamepad } from './gamepad';
|
||||||
import KeyBoard from './keyboard';
|
import KeyBoard from './keyboard';
|
||||||
@ -74,7 +75,7 @@ let stats: Stats;
|
|||||||
let vm: VideoModes;
|
let vm: VideoModes;
|
||||||
let tape: Tape;
|
let tape: Tape;
|
||||||
let _disk2: DiskII;
|
let _disk2: DiskII;
|
||||||
let _massStorage: MassStorage;
|
let _massStorage: MassStorage<BlockFormat>;
|
||||||
let _printer: Printer;
|
let _printer: Printer;
|
||||||
let audio: Audio;
|
let audio: Audio;
|
||||||
let screen: Screen;
|
let screen: Screen;
|
||||||
@ -270,8 +271,8 @@ export function doLoad(event: MouseEvent|KeyboardEvent) {
|
|||||||
} else if (url) {
|
} else if (url) {
|
||||||
let filename;
|
let filename;
|
||||||
MicroModal.close('load-modal');
|
MicroModal.close('load-modal');
|
||||||
if (url.substr(0, 6) === 'local:') {
|
if (url.slice(0, 6) === 'local:') {
|
||||||
filename = url.substr(6);
|
filename = url.slice(6);
|
||||||
if (filename === '__manage') {
|
if (filename === '__manage') {
|
||||||
openManage();
|
openManage();
|
||||||
} else {
|
} else {
|
||||||
@ -450,10 +451,8 @@ export function doLoadHTTP(drive: DriveNumber, url?: string) {
|
|||||||
const name = decodeURIComponent(fileParts.join('.'));
|
const name = decodeURIComponent(fileParts.join('.'));
|
||||||
if (includes(DISK_FORMATS, ext)) {
|
if (includes(DISK_FORMATS, ext)) {
|
||||||
if (data.byteLength >= 800 * 1024) {
|
if (data.byteLength >= 800 * 1024) {
|
||||||
if (
|
if (includes(BLOCK_FORMATS, ext)) {
|
||||||
includes(BLOCK_FORMATS, ext) &&
|
_massStorage.setBinary(drive, name, ext, data);
|
||||||
_massStorage.setBinary(drive, name, ext, data)
|
|
||||||
) {
|
|
||||||
initGamepad();
|
initGamepad();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -842,7 +841,12 @@ function hup() {
|
|||||||
return results[1];
|
return results[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
function onLoaded(apple2: Apple2, disk2: DiskII, massStorage: MassStorage, printer: Printer, e: boolean) {
|
function onLoaded(
|
||||||
|
apple2: Apple2,
|
||||||
|
disk2: DiskII,
|
||||||
|
massStorage: MassStorage<BlockFormat>,
|
||||||
|
printer: Printer, e: boolean
|
||||||
|
) {
|
||||||
_apple2 = apple2;
|
_apple2 = apple2;
|
||||||
cpu = _apple2.getCPU();
|
cpu = _apple2.getCPU();
|
||||||
io = _apple2.getIO();
|
io = _apple2.getIO();
|
||||||
@ -950,7 +954,11 @@ function onLoaded(apple2: Apple2, disk2: DiskII, massStorage: MassStorage, print
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initUI(apple2: Apple2, disk2: DiskII, massStorage: MassStorage, printer: Printer, e: boolean) {
|
export function initUI(
|
||||||
|
apple2: Apple2,
|
||||||
|
disk2: DiskII,
|
||||||
|
massStorage: MassStorage<BlockFormat>,
|
||||||
|
printer: Printer, e: boolean) {
|
||||||
window.addEventListener('load', () => {
|
window.addEventListener('load', () => {
|
||||||
onLoaded(apple2, disk2, massStorage, printer, e);
|
onLoaded(apple2, disk2, massStorage, printer, e);
|
||||||
});
|
});
|
||||||
|
@ -354,7 +354,7 @@ export default class KeyBoard {
|
|||||||
const buildLabel = (k: string) => {
|
const buildLabel = (k: string) => {
|
||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
span.innerHTML = k;
|
span.innerHTML = k;
|
||||||
if (k.length > 1 && k.substr(0, 1) !== '&')
|
if (k.length > 1 && k.slice(0, 1) !== '&')
|
||||||
span.classList.add('small');
|
span.classList.add('small');
|
||||||
return span;
|
return span;
|
||||||
};
|
};
|
||||||
|