mirror of
https://github.com/whscullin/apple2js.git
synced 2024-01-12 14:14:38 +00:00
Preact MVP
This commit is contained in:
parent
dc641c688a
commit
ad80029d63
|
@ -587,6 +587,7 @@ button:focus {
|
|||
|
||||
#reset-row .inset {
|
||||
margin: 0;
|
||||
width: 604px;
|
||||
}
|
||||
|
||||
#reset {
|
||||
|
@ -654,7 +655,6 @@ button:focus {
|
|||
|
||||
#options-modal {
|
||||
width: 300px;
|
||||
line-height: 1.75em;
|
||||
}
|
||||
|
||||
#options-modal h3 {
|
||||
|
|
|
@ -2,17 +2,34 @@ import 'preact/debug';
|
|||
import { h, Fragment } from 'preact';
|
||||
import { Header } from './Header';
|
||||
import { Apple2 } from './Apple2';
|
||||
import { usePrefs } from './hooks/usePrefs';
|
||||
import { SYSTEM_TYPE_APPLE2E } from '../ui/system';
|
||||
import { SCREEN_GL } from '../ui/screen';
|
||||
import { defaultSystem, systemTypes } from './util/systems';
|
||||
|
||||
/**
|
||||
* Top level application component, provides the parameters
|
||||
* needed by the Apple2 component to bootstrap itself
|
||||
*
|
||||
* @returns Application component
|
||||
*/
|
||||
|
||||
export const App = () => {
|
||||
const prefs = usePrefs();
|
||||
const systemType = prefs.readPref(SYSTEM_TYPE_APPLE2E, 'apple2enh');
|
||||
const gl = prefs.readPref(SCREEN_GL, 'true') === 'true';
|
||||
|
||||
const system = {
|
||||
...defaultSystem,
|
||||
...(systemTypes[systemType] || {})
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<Header e={system.e} />
|
||||
<Apple2
|
||||
e
|
||||
enhanced
|
||||
gl
|
||||
rom="apple2enh"
|
||||
characterRom="apple2enh_char"
|
||||
gl={gl}
|
||||
{...system}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { h } from 'preact';
|
||||
import cs from 'classnames';
|
||||
import {useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { Apple2 as Apple2Impl } from '../apple2';
|
||||
import Apple2IO from '../apple2io';
|
||||
|
@ -6,17 +7,32 @@ import { ControlStrip } from './ControlStrip';
|
|||
import { Inset } from './Inset';
|
||||
import { Keyboard } from './Keyboard';
|
||||
import { Screen } from './Screen';
|
||||
import { DiskII } from './DiskII';
|
||||
import { Drives } from './Drives';
|
||||
|
||||
/**
|
||||
* Interface for the Apple2 component
|
||||
*/
|
||||
export interface Apple2Props {
|
||||
characterRom: string;
|
||||
enhanced: boolean,
|
||||
e: boolean,
|
||||
gl: boolean,
|
||||
rom: string,
|
||||
characterRom: string
|
||||
enhanced: boolean
|
||||
e: boolean
|
||||
gl: boolean
|
||||
rom: string
|
||||
sectors: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Component to bind various UI components together to form
|
||||
* the application layout. Includes the screen, drives,
|
||||
* emulator controls and keyboard. Bootstraps the core
|
||||
* Apple2 emulator.
|
||||
*
|
||||
* @param props Apple2 initialization props
|
||||
* @returns
|
||||
*/
|
||||
|
||||
export const Apple2 = (props: Apple2Props) => {
|
||||
const { e, sectors } = props;
|
||||
const screen = useRef<HTMLCanvasElement>(null);
|
||||
const [apple2, setApple2] = useState<Apple2Impl>();
|
||||
const [io, setIO] = useState<Apple2IO>();
|
||||
|
@ -40,14 +56,14 @@ export const Apple2 = (props: Apple2Props) => {
|
|||
}, [screen.current]);
|
||||
|
||||
return (
|
||||
<div className="apple2e outer">
|
||||
<div className={cs('outer', { apple2e: e})}>
|
||||
<Screen screen={screen} />
|
||||
<Inset>
|
||||
<DiskII io={io} />
|
||||
<Drives io={io} sectors={sectors} />
|
||||
</Inset>
|
||||
<ControlStrip apple2={apple2} e={props.e} />
|
||||
<ControlStrip apple2={apple2} e={e} />
|
||||
<Inset>
|
||||
<Keyboard apple2={apple2} />
|
||||
<Keyboard apple2={apple2} e={e} />
|
||||
</Inset>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,11 +1,23 @@
|
|||
import { h } from 'preact';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { Apple2 as Apple2Impl } from '../apple2';
|
||||
import type { Stats} from '../apple2';
|
||||
|
||||
/**
|
||||
* Interface for CPUMeter
|
||||
*/
|
||||
export interface CPUMeterProps {
|
||||
apple2: Apple2Impl | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* A simple display that can cycle between emulator Khz
|
||||
* performance, frames/second and rendered frames/second
|
||||
*
|
||||
* @param apple2 Apple2 object
|
||||
* @returns CPU Meter component
|
||||
*/
|
||||
|
||||
export const CPUMeter = ({ apple2 }: CPUMeterProps) => {
|
||||
const lastStats = useRef<Stats>({
|
||||
frames: 0,
|
||||
|
@ -14,16 +26,30 @@ export const CPUMeter = ({ apple2 }: CPUMeterProps) => {
|
|||
});
|
||||
const lastTime = useRef<number>(Date.now());
|
||||
const [khz, setKhz] = useState<number>(0);
|
||||
const [fps, setFps] = useState<number>(0);
|
||||
const [rps, setRps] = useState<number>(0);
|
||||
const [mode, setMode] = useState<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
const { cycles, frames, renderedFrames } = lastStats.current;
|
||||
const stats = apple2?.getStats();
|
||||
const time = Date.now();
|
||||
const delta = time - lastTime.current;
|
||||
if (stats) {
|
||||
setKhz(
|
||||
Math.floor(
|
||||
(stats.cycles - lastStats.current.cycles) /
|
||||
(time - lastTime.current)
|
||||
(stats.cycles - cycles) / delta
|
||||
)
|
||||
);
|
||||
setFps(
|
||||
Math.floor(
|
||||
(stats.frames - frames) / delta * 1000
|
||||
)
|
||||
);
|
||||
setRps(
|
||||
Math.floor(
|
||||
(stats.renderedFrames - renderedFrames) / delta * 1000
|
||||
)
|
||||
);
|
||||
lastStats.current = { ...stats };
|
||||
|
@ -33,9 +59,15 @@ export const CPUMeter = ({ apple2 }: CPUMeterProps) => {
|
|||
return () => clearInterval(interval);
|
||||
}, [apple2]);
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
setMode((mode) => (mode + 1) % 3);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div id="khz">
|
||||
{khz} Khz
|
||||
<div id="khz" onClick={onClick}>
|
||||
{mode === 0 && `${khz} Khz`}
|
||||
{mode === 1 && `${fps} fps`}
|
||||
{mode === 2 && `${rps} rps`}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
26
js/components/ControlButton.tsx
Normal file
26
js/components/ControlButton.tsx
Normal file
|
@ -0,0 +1,26 @@
|
|||
import { h, JSX } from 'preact';
|
||||
|
||||
/**
|
||||
* Interface for ControlButton
|
||||
*/
|
||||
|
||||
export interface ControlButtonProps {
|
||||
icon: string
|
||||
title: string
|
||||
onClick: JSX.MouseEventHandler<HTMLButtonElement>
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple button with an icon, tooltip text and a callback
|
||||
*
|
||||
* @param icon FontAwesome icon name
|
||||
* @param title Tooltip text
|
||||
* @param onClick Click callback
|
||||
* @returns Control Button component
|
||||
*/
|
||||
|
||||
export const ControlButton = ({ icon, title, onClick }: ControlButtonProps) => (
|
||||
<button onClick={onClick} title={title}>
|
||||
<i class={`fas fa-${icon}`}></i>
|
||||
</button>
|
||||
);
|
|
@ -1,9 +1,16 @@
|
|||
import { h } from 'preact';
|
||||
import { useCallback, useEffect, useState } from 'preact/hooks';
|
||||
import { useCallback, useContext, useEffect, useState } from 'preact/hooks';
|
||||
import { CPUMeter } from './CPUMeter';
|
||||
import { Inset } from './Inset';
|
||||
import { useHotKey } from './hooks/useHotKey';
|
||||
import { Apple2 as Apple2Impl } from '../apple2';
|
||||
import { Audio } from '../ui/audio';
|
||||
import { Audio, SOUND_ENABLED_OPTION } from '../ui/audio';
|
||||
import { OptionsModal} from './OptionsModal';
|
||||
import { OptionsContext } from './OptionsContext';
|
||||
import { ControlButton } from './ControlButton';
|
||||
import { JoyStick } from '../ui/joystick';
|
||||
import { Screen, SCREEN_FULL_PAGE } from '../ui/screen';
|
||||
import { System } from '../ui/system';
|
||||
|
||||
const README = 'https://github.com/whscullin/apple2js#readme';
|
||||
|
||||
|
@ -12,15 +19,43 @@ interface ControlStripProps {
|
|||
e: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip containing containing controls for various system
|
||||
* characteristics, like CPU speed, audio, and the system
|
||||
* options panel.
|
||||
*
|
||||
* @param apple2 Apple2 object
|
||||
* @param e Whether or not this is a //e
|
||||
* @returns ControlStrip component
|
||||
*/
|
||||
|
||||
export const ControlStrip = ({ apple2, e }: ControlStripProps) => {
|
||||
const [running, setRunning] = useState(true);
|
||||
const [audio, setAudio] = useState<Audio>();
|
||||
const [audioEnabled, setAudioEnabled] = useState(false);
|
||||
const [showOptions, setShowOptions] = useState(false);
|
||||
const options = useContext(OptionsContext);
|
||||
|
||||
useEffect(() => {
|
||||
if (apple2) {
|
||||
apple2.ready.then(() =>
|
||||
setAudio(new Audio(apple2.getIO()))
|
||||
).catch(console.error);
|
||||
apple2.ready.then(() => {
|
||||
const io = apple2.getIO();
|
||||
const vm = apple2.getVideoModes();
|
||||
|
||||
const system = new System(io, e);
|
||||
options.addOptions(system);
|
||||
|
||||
const joystick = new JoyStick(io);
|
||||
options.addOptions(joystick);
|
||||
|
||||
const screen = new Screen(vm);
|
||||
options.addOptions(screen);
|
||||
|
||||
const audio = new Audio(io);
|
||||
options.addOptions(audio);
|
||||
setAudio(audio);
|
||||
setAudioEnabled(audio.isEnabled());
|
||||
}).catch(console.error);
|
||||
}
|
||||
}, [apple2]);
|
||||
|
||||
|
@ -35,37 +70,64 @@ export const ControlStrip = ({ apple2, e }: ControlStripProps) => {
|
|||
}, [apple2]);
|
||||
|
||||
const doToggleSound = useCallback(() => {
|
||||
audio?.isEnabled();
|
||||
const on = !audio?.isEnabled();
|
||||
options.setOption(SOUND_ENABLED_OPTION, on);
|
||||
setAudioEnabled(on);
|
||||
}, [audio]);
|
||||
|
||||
const doReset = useCallback(() => {
|
||||
apple2?.reset();
|
||||
}, [apple2]);
|
||||
const doReset = useCallback(() =>
|
||||
apple2?.reset()
|
||||
, [apple2]);
|
||||
|
||||
const doReadme = useCallback(() => {
|
||||
window.open(README, '_blank');
|
||||
}, []);
|
||||
const doReadme = useCallback(() =>
|
||||
window.open(README, '_blank')
|
||||
, []);
|
||||
|
||||
const doShowOptions = useCallback(() =>
|
||||
setShowOptions(true)
|
||||
, []);
|
||||
|
||||
const doCloseOptions = useCallback(() =>
|
||||
setShowOptions(false)
|
||||
, []);
|
||||
|
||||
const doToggleFullPage = useCallback(() =>
|
||||
options.setOption(
|
||||
SCREEN_FULL_PAGE,
|
||||
!options.getOption(SCREEN_FULL_PAGE)
|
||||
)
|
||||
, []);
|
||||
|
||||
useHotKey('F2', doToggleFullPage);
|
||||
useHotKey('F4', doShowOptions);
|
||||
useHotKey('F12', doReset);
|
||||
|
||||
return (
|
||||
<div id="reset-row">
|
||||
<OptionsModal isOpen={showOptions} onClose={doCloseOptions} />
|
||||
<Inset>
|
||||
<CPUMeter apple2={apple2} />
|
||||
{running ? (
|
||||
<button onClick={doPause} title="About">
|
||||
<i class="fas fa-pause"></i>
|
||||
</button>
|
||||
<ControlButton
|
||||
onClick={doPause}
|
||||
title="Pause"
|
||||
icon="pause"
|
||||
/>
|
||||
) : (
|
||||
<button onClick={doRun} title="About">
|
||||
<i class="fas fa-play"></i>
|
||||
</button>
|
||||
<ControlButton
|
||||
onClick={doRun}
|
||||
title="Run"
|
||||
icon="play"
|
||||
/>
|
||||
)}
|
||||
<button id="toggle-sound" onClick={doToggleSound} title="Toggle Sound">
|
||||
<i class="fas fa-volume-off"></i>
|
||||
</button>
|
||||
<ControlButton
|
||||
onClick={doToggleSound}
|
||||
title="Toggle Sound"
|
||||
icon={audioEnabled ? 'volume-up' : 'volume-off'}
|
||||
/>
|
||||
<div style={{flexGrow: 1}} />
|
||||
<button onClick={doReadme} title="About">
|
||||
<i class="fas fa-info"></i>
|
||||
</button>
|
||||
<ControlButton onClick={doReadme} title="About" icon="info" />
|
||||
<ControlButton onClick={doShowOptions} title="Options (F4)" icon="cog" />
|
||||
</Inset>
|
||||
{e && (
|
||||
<button id="reset" onClick={doReset}>
|
||||
|
|
|
@ -1,37 +1,67 @@
|
|||
import { h, Fragment } from 'preact';
|
||||
import { h } from 'preact';
|
||||
import { useCallback, useEffect, useState } from 'preact/hooks';
|
||||
import classNames from 'classnames';
|
||||
import Disk2, { Callbacks } from '../cards/disk2';
|
||||
import { NibbleFormat } from '../formats/types';
|
||||
import Apple2IO from '../apple2io';
|
||||
import cs from 'classnames';
|
||||
import Disk2 from '../cards/disk2';
|
||||
import { FileModal } from './FileModal';
|
||||
import { loadJSON, loadHttpFile, getHashParts } from './util/files';
|
||||
|
||||
export interface DiskIIProps {
|
||||
io?: Apple2IO
|
||||
}
|
||||
/**
|
||||
* Storage structure for Disk II state returned via callbacks
|
||||
*/
|
||||
|
||||
interface DriveData {
|
||||
export interface DiskIIData {
|
||||
number: 1 | 2
|
||||
on: boolean
|
||||
name?: string
|
||||
side?: string
|
||||
}
|
||||
|
||||
interface DriveProps extends DriveData {
|
||||
/**
|
||||
* Interface for Disk II component
|
||||
*/
|
||||
|
||||
export interface DiskIIProps extends DiskIIData {
|
||||
disk2?: Disk2
|
||||
}
|
||||
|
||||
const Drive = ({ disk2, number, on, name, side }: DriveProps) => {
|
||||
const label = side ? `${name} - ${side}` : name;
|
||||
/**
|
||||
* Disk II component
|
||||
*
|
||||
* Include 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 number Drive 1 or 2
|
||||
* @param on Active state
|
||||
* @param name Disk name identifier
|
||||
* @param side Disk side identifier
|
||||
* @returns DiskII component
|
||||
*/
|
||||
|
||||
export const DiskII = ({ disk2, number, on, name, side }: DiskIIProps) => {
|
||||
const label = side ? `${name} - ${side}` : name;
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
const onOpen = useCallback((name: string, fmt: NibbleFormat, rawData: ArrayBuffer) => {
|
||||
setModalOpen(false);
|
||||
return disk2?.setBinary(number, name, fmt, rawData) || false;
|
||||
}, [disk2, number]);
|
||||
useEffect(() => {
|
||||
const hashParts = getHashParts();
|
||||
if (disk2 && hashParts && hashParts[number]) {
|
||||
const hashPart = decodeURIComponent(hashParts[number]);
|
||||
if (hashPart.match(/^https?:/)) {
|
||||
loadHttpFile(disk2, number, hashPart)
|
||||
.catch((error) =>
|
||||
console.error(error)
|
||||
);
|
||||
} else {
|
||||
const filename = `/json/disks/${hashPart}.json`;
|
||||
loadJSON(disk2, number, filename)
|
||||
.catch((error) =>
|
||||
console.error(error)
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [disk2]);
|
||||
|
||||
const onCancel = useCallback(() => {
|
||||
const doClose = useCallback(() => {
|
||||
setModalOpen(false);
|
||||
}, []);
|
||||
|
||||
|
@ -41,10 +71,10 @@ const Drive = ({ disk2, number, on, name, side }: DriveProps) => {
|
|||
|
||||
return (
|
||||
<div className="disk">
|
||||
<FileModal onOpen={onOpen} onCancel={onCancel} show={modalOpen} />
|
||||
<FileModal disk2={disk2} number={number} onClose={doClose} isOpen={modalOpen} />
|
||||
<div
|
||||
id={`disk${number}`}
|
||||
className={classNames('disk-light', { on })}
|
||||
className={cs('disk-light', { on })}
|
||||
/>
|
||||
<button title="Load Disk">
|
||||
<i class="fas fa-folder-open" onClick={onOpenModal} />
|
||||
|
@ -58,36 +88,3 @@ const Drive = ({ disk2, number, on, name, side }: DriveProps) => {
|
|||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export const DiskII = ({ io }: DiskIIProps) => {
|
||||
const [disk2, setDisk2] = useState<Disk2>();
|
||||
const [data1, setData1] = useState<DriveData>({ on: false, number: 1, name: 'Disk1' });
|
||||
const [data2, setData2] = useState<DriveData>({ on: false, number: 2, name: 'Disk2' });
|
||||
|
||||
useEffect(() => {
|
||||
const setData = [setData1, setData2];
|
||||
const callbacks: Callbacks = {
|
||||
driveLight: (drive, on) => {
|
||||
setData[drive - 1]?.(data => ({...data, on }));
|
||||
},
|
||||
label: (drive, name, side) => {
|
||||
setData[drive - 1]?.(data => ({...data, name, side }));
|
||||
},
|
||||
dirty: () => {}
|
||||
};
|
||||
|
||||
if (io) {
|
||||
const disk2 = new Disk2(io, callbacks);
|
||||
io.setSlot(6, disk2);
|
||||
setDisk2(disk2);
|
||||
}
|
||||
}, [io]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drive disk2={disk2} {...data1} />
|
||||
<Drive disk2={disk2} {...data2} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
55
js/components/Drives.tsx
Normal file
55
js/components/Drives.tsx
Normal file
|
@ -0,0 +1,55 @@
|
|||
import { h, Fragment } from 'preact';
|
||||
import {useEffect, useState } from 'preact/hooks';
|
||||
import Disk2, { Callbacks } from '../cards/disk2';
|
||||
import Apple2IO from '../apple2io';
|
||||
import { DiskII, DiskIIData } from './DiskII';
|
||||
|
||||
/**
|
||||
* Interface for Drives component
|
||||
*/
|
||||
|
||||
export interface DrivesProps {
|
||||
io?: Apple2IO
|
||||
sectors: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive interface component. Presents the interface to load disks.
|
||||
* Provides the callback to the Disk2 object to update the DiskII
|
||||
* components
|
||||
*
|
||||
* @param io Apple I/O object
|
||||
* @returns Drives component
|
||||
*/
|
||||
|
||||
export const Drives = ({ io, sectors }: DrivesProps) => {
|
||||
const [disk2, setDisk2] = useState<Disk2>();
|
||||
const [data1, setData1] = useState<DiskIIData>({ on: false, number: 1, name: 'Disk 1' });
|
||||
const [data2, setData2] = useState<DiskIIData>({ on: false, number: 2, name: 'Disk 2' });
|
||||
|
||||
useEffect(() => {
|
||||
const setData = [setData1, setData2];
|
||||
const callbacks: Callbacks = {
|
||||
driveLight: (drive, on) => {
|
||||
setData[drive - 1]?.(data => ({...data, on }));
|
||||
},
|
||||
label: (drive, name, side) => {
|
||||
setData[drive - 1]?.(data => ({...data, name, side }));
|
||||
},
|
||||
dirty: () => {}
|
||||
};
|
||||
|
||||
if (io) {
|
||||
const disk2 = new Disk2(io, callbacks, sectors);
|
||||
io.setSlot(6, disk2);
|
||||
setDisk2(disk2);
|
||||
}
|
||||
}, [io]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DiskII disk2={disk2} {...data1} />
|
||||
<DiskII disk2={disk2} {...data2} />
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -1,51 +1,77 @@
|
|||
import { h } from 'preact';
|
||||
import { h, JSX } from 'preact';
|
||||
import { useCallback, useRef, useState } from 'preact/hooks';
|
||||
import { includes } from '../types';
|
||||
import { NibbleFormat, DISK_FORMATS, NIBBLE_FORMATS } from '../formats/types';
|
||||
import { DiskDescriptor, DriveNumber, NibbleFormat } from '../formats/types';
|
||||
import { Modal, ModalContent, ModalFooter } from './Modal';
|
||||
import { initGamepad } from '../ui/gamepad';
|
||||
import { loadLocalFile, loadJSON, getHashParts, setHashParts } from './util/files';
|
||||
import DiskII from '../cards/disk2';
|
||||
|
||||
import index from 'json/disks/index.json';
|
||||
|
||||
const categories = index.reduce<Record<string, DiskDescriptor[]>>(
|
||||
(
|
||||
acc: Record<string, DiskDescriptor[]>,
|
||||
disk: DiskDescriptor
|
||||
) => {
|
||||
const category = disk.category || 'Misc';
|
||||
acc[category] = [disk, ...(acc[category] || [])];
|
||||
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
const categoryNames = Object.keys(categories).sort();
|
||||
|
||||
export type NibbleFileCallback = (
|
||||
name: string,
|
||||
fmt: NibbleFormat,
|
||||
rawData: ArrayBuffer
|
||||
) => boolean
|
||||
|
||||
interface FileModalProps {
|
||||
show: boolean
|
||||
onOpen: (name: string, fmt: NibbleFormat, rawData: ArrayBuffer) => boolean
|
||||
onCancel: () => void
|
||||
isOpen: boolean
|
||||
disk2?: DiskII,
|
||||
number: DriveNumber,
|
||||
onClose: (closeBox?: boolean) => void
|
||||
}
|
||||
|
||||
export const FileModal = ({ show, onOpen, onCancel } : FileModalProps) => {
|
||||
export const FileModal = ({ disk2, number, onClose, isOpen } : FileModalProps) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [busy, setBusy] = useState<boolean>(false);
|
||||
const [empty, setEmpty] = useState<boolean>(true);
|
||||
const [category, setCategory] = useState<string>();
|
||||
const [filename, setFilename] = useState<string>();
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
if (inputRef.current && inputRef.current.files?.length === 1) {
|
||||
const doCancel = useCallback(() => onClose(true), []);
|
||||
|
||||
const doOpen = useCallback(() => {
|
||||
const hashParts = getHashParts();
|
||||
|
||||
if (disk2 && inputRef.current && inputRef.current.files?.length === 1) {
|
||||
hashParts[number] = '';
|
||||
setBusy(true);
|
||||
const file = inputRef.current.files[0];
|
||||
const fileReader = new FileReader();
|
||||
fileReader.onload = function () {
|
||||
const result = this.result as ArrayBuffer;
|
||||
const parts = file.name.split('.');
|
||||
const ext = parts.pop()!.toLowerCase();
|
||||
const name = parts.join('.');
|
||||
|
||||
if (includes(DISK_FORMATS, ext)) {
|
||||
if (result.byteLength >= 800 * 1024) {
|
||||
console.error(`Unable to load ${name}`);
|
||||
} else {
|
||||
if (
|
||||
includes(NIBBLE_FORMATS, ext) &&
|
||||
onOpen(name, ext, result)
|
||||
) {
|
||||
initGamepad();
|
||||
} else {
|
||||
console.error(`Unable to load ${name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
fileReader.readAsArrayBuffer(file);
|
||||
loadLocalFile(disk2, number, inputRef.current.files[0])
|
||||
.catch(console.error)
|
||||
.finally(() => {
|
||||
setBusy(false);
|
||||
onClose();
|
||||
});
|
||||
}
|
||||
}, [ onOpen ]);
|
||||
|
||||
if (disk2 && filename) {
|
||||
const name = filename.match(/\/([^/]+).json$/) || ['', ''];
|
||||
hashParts[number] = name[1];
|
||||
setBusy(true);
|
||||
loadJSON(disk2, number, filename)
|
||||
.catch(console.error)
|
||||
.finally(() => {
|
||||
setBusy(false);
|
||||
onClose();
|
||||
});
|
||||
}
|
||||
|
||||
setHashParts(hashParts);
|
||||
}, [ disk2, filename, number, onClose ]);
|
||||
|
||||
const onChange = useCallback(() => {
|
||||
if (inputRef) {
|
||||
|
@ -53,14 +79,44 @@ export const FileModal = ({ show, onOpen, onCancel } : FileModalProps) => {
|
|||
}
|
||||
}, [ inputRef ]);
|
||||
|
||||
const doSelectCategory = useCallback(
|
||||
(event: JSX.TargetedMouseEvent<HTMLSelectElement>) =>
|
||||
setCategory(event.currentTarget.value)
|
||||
, []
|
||||
);
|
||||
|
||||
const doSelectFilename = useCallback(
|
||||
(event: JSX.TargetedMouseEvent<HTMLSelectElement>) => {
|
||||
setEmpty(!event.currentTarget.value);
|
||||
setFilename(event.currentTarget.value);
|
||||
}, []
|
||||
);
|
||||
|
||||
const disks = category ? categories[category] : [];
|
||||
|
||||
return (
|
||||
<Modal title="Open File" show={show}>
|
||||
<Modal title="Open File" isOpen={isOpen}>
|
||||
<ModalContent>
|
||||
<div id="load-modal">
|
||||
<select multiple onChange={doSelectCategory}>
|
||||
{categoryNames.map((name) => (
|
||||
<option>{name}</option>
|
||||
))}
|
||||
</select>
|
||||
<select multiple onChange={doSelectFilename}>
|
||||
{disks.map((disk) => (
|
||||
<option value={disk.filename}>
|
||||
{disk.name}
|
||||
{disk.disk ? ` - ${disk.disk}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<input type="file" ref={inputRef} onChange={onChange} />
|
||||
</ModalContent>
|
||||
<ModalFooter>
|
||||
<button onClick={onCancel}>Cancel</button>
|
||||
<button onClick={onClick} disabled={busy || empty}>Open</button>
|
||||
<button onClick={doCancel}>Cancel</button>
|
||||
<button onClick={doOpen} disabled={busy || empty}>Open</button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
);
|
||||
|
|
|
@ -1,12 +1,25 @@
|
|||
import { h } from 'preact';
|
||||
|
||||
export const Header = () => {
|
||||
/**
|
||||
* Header component properties
|
||||
*/
|
||||
export interface HeaderProps {
|
||||
e: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Header component, which consists of a badge and title
|
||||
*
|
||||
* @returns Header component
|
||||
*/
|
||||
|
||||
export const Header = ({ e }: HeaderProps) => {
|
||||
return (
|
||||
<div id="header">
|
||||
<a href="https://github.com/whscullin/apple2js#readme" target="_blank">
|
||||
<img src="img/badge.png" id="badge" />
|
||||
</a>
|
||||
<div id="subtitle">An Apple ][ Emulator in JavaScript</div>
|
||||
<div id="subtitle">An Apple {e ? '//e' : ']['} Emulator in JavaScript</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,6 +1,12 @@
|
|||
import { h, FunctionComponent } from 'preact';
|
||||
import { h, FunctionalComponent } from 'preact';
|
||||
|
||||
export const Inset: FunctionComponent = ({ children }) => (
|
||||
/**
|
||||
* Convenience component for a nice beveled border
|
||||
*
|
||||
* @returns Inset component
|
||||
*/
|
||||
|
||||
export const Inset: FunctionalComponent = ({ children }) => (
|
||||
<div className="inset">
|
||||
{children}
|
||||
</div>
|
||||
|
|
|
@ -1,39 +1,37 @@
|
|||
import { h, Fragment, JSX } from 'preact';
|
||||
import classNames from 'classnames';
|
||||
import { useCallback, useEffect, useState } from 'preact/hooks';
|
||||
import cs from 'classnames';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'preact/hooks';
|
||||
import { Apple2 as Apple2Impl } from '../apple2';
|
||||
import { mapKeyEvent, keys2e, mapMouseEvent } from './util/keyboard';
|
||||
import {
|
||||
keys2,
|
||||
keys2e,
|
||||
mapKeyEvent,
|
||||
mapMouseEvent,
|
||||
keysAsTuples
|
||||
} from './util/keyboard';
|
||||
|
||||
export interface KeyboardProps {
|
||||
apple2: Apple2Impl | undefined
|
||||
}
|
||||
|
||||
const keysAsTuples = (): string[][][] => {
|
||||
const rows = [];
|
||||
for (let idx = 0; idx < keys2e[0].length; idx++) {
|
||||
const upper = keys2e[0][idx];
|
||||
const lower = keys2e[1][idx];
|
||||
const keys = [];
|
||||
for (let jdx = 0; jdx < upper.length; jdx++) {
|
||||
keys.push([upper[jdx], lower[jdx]]);
|
||||
}
|
||||
rows.push(keys);
|
||||
}
|
||||
return rows;
|
||||
};
|
||||
|
||||
const keys = keysAsTuples();
|
||||
/**
|
||||
* Convenience function for massaging key labels for upper
|
||||
* and lower case
|
||||
*
|
||||
* @param key Raw key label
|
||||
* @returns Span representing that label
|
||||
*/
|
||||
|
||||
const buildLabel = (key: string) => {
|
||||
const small = key.length > 1 && !key.startsWith('&');
|
||||
return (
|
||||
<span
|
||||
className={classNames({ small })}
|
||||
dangerouslySetInnerHTML={{__html: key}}
|
||||
className={cs({ small })}
|
||||
dangerouslySetInnerHTML={{ __html: key }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Key properties
|
||||
*/
|
||||
|
||||
interface KeyProps {
|
||||
lower: string
|
||||
upper: string
|
||||
|
@ -43,18 +41,36 @@ interface KeyProps {
|
|||
onMouseUp: (event: MouseEvent) => void
|
||||
}
|
||||
|
||||
export const Key = ({ lower, upper, active, pressed, onMouseDown, onMouseUp }: KeyProps) => {
|
||||
/**
|
||||
* Individual Key components. Sets up DOM data attributes to be passed to mouse
|
||||
* handlers
|
||||
*
|
||||
* @param lower Lower key symbol
|
||||
* @param upper Upper key symbol
|
||||
* @param active Active state for shift, control, lock
|
||||
* @param pressed Pressed state
|
||||
* @param onMouseDown mouse down callback
|
||||
* @param onMouseUp mouse up callback
|
||||
*/
|
||||
export const Key = ({
|
||||
lower,
|
||||
upper,
|
||||
active,
|
||||
pressed,
|
||||
onMouseDown,
|
||||
onMouseUp
|
||||
}: KeyProps) => {
|
||||
const keyName = lower.replace(/[&#;]/g, '');
|
||||
const center =
|
||||
lower === 'LOCK' ?
|
||||
'v-center2' :
|
||||
(upper === lower && upper.length > 0 ?
|
||||
(upper === lower && upper.length > 1 ?
|
||||
'v-center'
|
||||
: ''
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
className={cs(
|
||||
'key',
|
||||
`key-${keyName}`,
|
||||
center,
|
||||
|
@ -73,15 +89,34 @@ export const Key = ({ lower, upper, active, pressed, onMouseDown, onMouseUp }: K
|
|||
);
|
||||
};
|
||||
|
||||
export const Keyboard = ({ apple2 }: KeyboardProps) => {
|
||||
/**
|
||||
* Keyboard properties
|
||||
*/
|
||||
export interface KeyboardProps {
|
||||
apple2: Apple2Impl | undefined
|
||||
e: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyboard component that can render an Apple ][ or //e keyboard
|
||||
* and accept keyboard and mouse input. Relies heavily on the
|
||||
* ancient keyboard css to achieve its appearance.
|
||||
*
|
||||
* @param apple2 Apple2 object
|
||||
* @returns Keyboard component
|
||||
*/
|
||||
|
||||
export const Keyboard = ({ apple2, e }: KeyboardProps) => {
|
||||
const [pressed, setPressed] = useState<string[]>([]);
|
||||
const [active, setActive] = useState<string[]>(['LOCK']);
|
||||
const keys = useMemo(() => keysAsTuples(e ? keys2e : keys2 ), [e]);
|
||||
|
||||
// Set global keystroke handler
|
||||
useEffect(() => {
|
||||
const keyDown = (event: KeyboardEvent) => {
|
||||
const key = mapKeyEvent(event, active.includes('LOCK'));
|
||||
if (key !== 0xff) {
|
||||
// CTRL-SHIFT-DELETE for reset
|
||||
if (key === 0x7F && event.shiftKey && event.ctrlKey) {
|
||||
apple2?.reset();
|
||||
} else {
|
||||
|
@ -151,19 +186,22 @@ export const Keyboard = ({ apple2 }: KeyboardProps) => {
|
|||
[apple2, active, pressed]
|
||||
);
|
||||
|
||||
const onMouseUp = useCallback((event: JSX.TargetedMouseEvent<HTMLElement>) => {
|
||||
const { keyLabel } = mapMouseEvent(
|
||||
event,
|
||||
active.includes('SHIFT'),
|
||||
active.includes('CTRL'),
|
||||
active.includes('LOCK'),
|
||||
true
|
||||
);
|
||||
apple2?.getIO().keyUp();
|
||||
setPressed(pressed.filter(x => x !== keyLabel));
|
||||
}, [apple2, active, pressed]);
|
||||
const onMouseUp = useCallback(
|
||||
(event: JSX.TargetedMouseEvent<HTMLElement>) => {
|
||||
const { keyLabel } = mapMouseEvent(
|
||||
event,
|
||||
active.includes('SHIFT'),
|
||||
active.includes('CTRL'),
|
||||
active.includes('LOCK'),
|
||||
true
|
||||
);
|
||||
apple2?.getIO().keyUp();
|
||||
setPressed(pressed.filter(x => x !== keyLabel));
|
||||
},
|
||||
[apple2, active, pressed]
|
||||
);
|
||||
|
||||
const bindKey = ([lower, upper] : [string, string]) =>
|
||||
const bindKey = ([lower, upper]: [string, string]) =>
|
||||
<Key
|
||||
lower={lower}
|
||||
upper={upper}
|
||||
|
@ -180,7 +218,7 @@ export const Keyboard = ({ apple2 }: KeyboardProps) => {
|
|||
);
|
||||
|
||||
return (
|
||||
<div id="keyboard">
|
||||
<div id="keyboard" style={{ marginLeft: e ? 0 : 15 }}>
|
||||
{rows}
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,4 +1,10 @@
|
|||
import { h, FunctionalComponent } from 'preact';
|
||||
import { useCallback } from 'preact/hooks';
|
||||
|
||||
/**
|
||||
* Temporary JS styling while I figure out how I really want
|
||||
* to do it.
|
||||
*/
|
||||
|
||||
const modalOverlayStyle = {
|
||||
position: 'fixed',
|
||||
|
@ -10,6 +16,7 @@ const modalOverlayStyle = {
|
|||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'rgba(0,0,0,0.6)',
|
||||
zIndex: 1,
|
||||
};
|
||||
|
||||
const modalStyle = {
|
||||
|
@ -21,7 +28,7 @@ const modalStyle = {
|
|||
boxSizing: 'border-box',
|
||||
};
|
||||
|
||||
const modalTitleStyle = {
|
||||
const modalHeaderStyle = {
|
||||
display: 'flex',
|
||||
fontSize: '14px',
|
||||
justifyContent: 'space-between',
|
||||
|
@ -33,10 +40,20 @@ const modalTitleStyle = {
|
|||
borderRadius: '3px',
|
||||
};
|
||||
|
||||
const modalTitleStyle = {
|
||||
marginTop: 0,
|
||||
marginBottom: 0,
|
||||
fontWeight: 600,
|
||||
fontSize: '1.25rem',
|
||||
lineHeight: 1.25,
|
||||
color: '#fff',
|
||||
boxSizing: 'border-box',
|
||||
};
|
||||
|
||||
const modalContentStyle = {
|
||||
marginTop: '10px',
|
||||
marginBottom: '10px',
|
||||
lineHeight: '1.5',
|
||||
lineHeight: 1.5,
|
||||
color: '#000'
|
||||
};
|
||||
|
||||
|
@ -44,10 +61,12 @@ const modalFooterStyle = {
|
|||
textAlign: 'right'
|
||||
};
|
||||
|
||||
export interface ModalProps {
|
||||
show: boolean
|
||||
title: string
|
||||
}
|
||||
/**
|
||||
* ModalOverlay creates a semi-transparent overlay in which the
|
||||
* modal is centered.
|
||||
*
|
||||
* @returns ModalOverlay component
|
||||
*/
|
||||
|
||||
export const ModalOverlay: FunctionalComponent = ({ children }) => {
|
||||
return (
|
||||
|
@ -57,6 +76,12 @@ export const ModalOverlay: FunctionalComponent = ({ children }) => {
|
|||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* ModalContent provides a styled container for modal content
|
||||
*
|
||||
* @returns ModalContent component
|
||||
*/
|
||||
|
||||
export const ModalContent: FunctionalComponent = ({ children }) => {
|
||||
return (
|
||||
<div style={modalContentStyle}>
|
||||
|
@ -65,6 +90,12 @@ export const ModalContent: FunctionalComponent = ({ children }) => {
|
|||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* ModalFooter provides a right-aligned container for modal buttons
|
||||
*
|
||||
* @returns ModalFooter component
|
||||
*/
|
||||
|
||||
export const ModalFooter: FunctionalComponent = ({ children }) => {
|
||||
return (
|
||||
<div style={modalFooterStyle}>
|
||||
|
@ -73,14 +104,69 @@ export const ModalFooter: FunctionalComponent = ({ children }) => {
|
|||
);
|
||||
};
|
||||
|
||||
export const Modal: FunctionalComponent<ModalProps> = ({ show, title, children }) => {
|
||||
/**
|
||||
* ModalHeader component properties
|
||||
*/
|
||||
|
||||
export interface ModalHeaderProps {
|
||||
onClose?: (closeBox?: boolean) => void
|
||||
title: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Header used internally for Modal component
|
||||
*
|
||||
* @param onClose Close callback
|
||||
* @param title Modal title
|
||||
* @returns
|
||||
*/
|
||||
|
||||
export const ModalHeader = ({ onClose, title }: ModalHeaderProps) => {
|
||||
const doClose = useCallback(() => onClose?.(true), [onClose]);
|
||||
|
||||
return (
|
||||
show ? (
|
||||
<div style={modalHeaderStyle}>
|
||||
<span style={modalTitleStyle}>{title}</span>
|
||||
{onClose && (
|
||||
<button onClick={doClose} title="Close">
|
||||
{'\u2715'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Modal component properties
|
||||
*/
|
||||
export interface ModalProps {
|
||||
onClose?: (closeBox?: boolean) => void
|
||||
isOpen: boolean
|
||||
title: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Very simple modal component, provides a transparent overlay, title bar
|
||||
* with optional close box if onClose is provided. ModalContent and
|
||||
* ModalFooter components are provided for convenience but not required.
|
||||
*
|
||||
* @param isOpen true to show modal
|
||||
* @param title Modal title
|
||||
* @onClose Close callback
|
||||
* @returns Modal component
|
||||
*/
|
||||
|
||||
export const Modal: FunctionalComponent<ModalProps> = ({
|
||||
isOpen,
|
||||
title,
|
||||
children,
|
||||
onClose
|
||||
}) => {
|
||||
return (
|
||||
isOpen ? (
|
||||
<ModalOverlay>
|
||||
<div style={modalStyle}>
|
||||
<div style={modalTitleStyle}>
|
||||
{title}
|
||||
</div>
|
||||
{title && <ModalHeader onClose={onClose} title={title} />}
|
||||
{children}
|
||||
</div>
|
||||
</ModalOverlay>
|
||||
|
|
|
@ -1,4 +1,8 @@
|
|||
import { createContext } from 'preact';
|
||||
import { OptionsModal } from '../ui/options_modal';
|
||||
import { Options } from '../options';
|
||||
|
||||
export const OptionsContext = createContext(new OptionsModal());
|
||||
/**
|
||||
* Context for getting, setting and configuring options
|
||||
*/
|
||||
|
||||
export const OptionsContext = createContext(new Options());
|
||||
|
|
|
@ -1,47 +1,170 @@
|
|||
import { h } from 'preact';
|
||||
import { useContext } from 'preact/hooks';
|
||||
import { Modal } from './Modal';
|
||||
import { h, Fragment, JSX } from 'preact';
|
||||
import { useCallback, useContext } from 'preact/hooks';
|
||||
import { Modal, ModalContent, ModalFooter } from './Modal';
|
||||
import { OptionsContext } from './OptionsContext';
|
||||
import {
|
||||
BOOLEAN_OPTION,
|
||||
SELECT_OPTION,
|
||||
BooleanOption,
|
||||
Option,
|
||||
OptionsModal as Options,
|
||||
OptionSection
|
||||
} from '../ui/options_modal';
|
||||
OptionSection,
|
||||
SelectOption,
|
||||
} from '../options';
|
||||
|
||||
export interface OptionsModalProps {
|
||||
show: boolean
|
||||
/**
|
||||
* Boolean property interface
|
||||
*/
|
||||
|
||||
interface BooleanProps {
|
||||
option: BooleanOption
|
||||
value: boolean
|
||||
setValue: (name: string, value: boolean) => void
|
||||
}
|
||||
|
||||
const Boolean = (options: Options, option: BooleanOption) => {
|
||||
/**
|
||||
*
|
||||
* @param option Boolean option
|
||||
* @param value Current value
|
||||
* @param setValue Value setter
|
||||
* @returns Boolean component
|
||||
*/
|
||||
const Boolean = ({ option, value, setValue } : BooleanProps) => {
|
||||
const { label, name } = option;
|
||||
const onChange = useCallback(
|
||||
(event: JSX.TargetedMouseEvent<HTMLInputElement>) =>
|
||||
setValue(name, event.currentTarget.checked)
|
||||
, [name, setValue]
|
||||
);
|
||||
|
||||
return (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={options.getOption(option.name)}
|
||||
/>
|
||||
)
|
||||
<li>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<label>{label}</label>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export const OptionsModal = ({ show }: OptionsModalProps) => {
|
||||
/**
|
||||
* Select property interface
|
||||
*/
|
||||
|
||||
interface SelectProps {
|
||||
option: SelectOption
|
||||
value: string
|
||||
setValue: (name: string, value: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Select component that provides a dropdown to choose between
|
||||
* options.
|
||||
*
|
||||
* @param option Select option
|
||||
* @param value Current value
|
||||
* @param setValue Value setter
|
||||
* @returns Select component
|
||||
*/
|
||||
|
||||
const Select = ({ option, value, setValue } : SelectProps) => {
|
||||
const { label, name } = option;
|
||||
const onChange = useCallback(
|
||||
(event: JSX.TargetedMouseEvent<HTMLSelectElement>) => {
|
||||
setValue(name, event.currentTarget.value);
|
||||
},
|
||||
[name, setValue]
|
||||
);
|
||||
|
||||
const makeOption = (option: { name: string, value: string }) => (
|
||||
<option selected={option.value === value} value={option.value}>
|
||||
{option.name}
|
||||
</option>
|
||||
);
|
||||
|
||||
return (
|
||||
<li>
|
||||
<select onChange={onChange}>
|
||||
{option.values.map(makeOption)}
|
||||
</select>
|
||||
<label>{label}</label>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* OptionsModal properties
|
||||
*/
|
||||
export interface OptionsModalProps {
|
||||
isOpen: boolean
|
||||
onClose: (closeBox?: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal to allow editing of various component provided
|
||||
* options, like screen and cpu type
|
||||
*
|
||||
* @param Modal params
|
||||
* @returns OptionsModal component
|
||||
*/
|
||||
|
||||
export const OptionsModal = ({ isOpen, onClose }: OptionsModalProps) => {
|
||||
const options = useContext(OptionsContext);
|
||||
const sections = options.getSections()
|
||||
const sections = options.getSections();
|
||||
const setValue = useCallback(( name: string, value: string | boolean ) => {
|
||||
options.setOption(name, value);
|
||||
}, [options]);
|
||||
|
||||
const makeOption = (option: Option) => {
|
||||
|
||||
}
|
||||
const { name, type } = option;
|
||||
const value = options.getOption(name);
|
||||
switch (type) {
|
||||
case BOOLEAN_OPTION:
|
||||
return (
|
||||
<Boolean
|
||||
option={option as BooleanOption}
|
||||
value={value as boolean}
|
||||
setValue={setValue}
|
||||
/>
|
||||
);
|
||||
case SELECT_OPTION:
|
||||
return (
|
||||
<Select
|
||||
option={option as SelectOption}
|
||||
value={value as string}
|
||||
setValue={setValue}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const makeSection = (section: OptionSection) => {
|
||||
return (
|
||||
<div>
|
||||
<div>{section.name}</div>
|
||||
{section.options.map(makeOption)}
|
||||
</div>
|
||||
<>
|
||||
<h3>{section.name}</h3>
|
||||
<ul>
|
||||
{section.options.map(makeOption)}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const doClose = useCallback(() => onClose(), [onClose]);
|
||||
|
||||
return (
|
||||
<Modal title="Options" show={show}>
|
||||
{sections.map(makeSection)}
|
||||
<Modal title="Options" isOpen={isOpen} onClose={onClose}>
|
||||
<ModalContent>
|
||||
<div id="options-modal">
|
||||
{sections.map(makeSection)}
|
||||
</div>
|
||||
<i>* Reload page to take effect</i>
|
||||
</ModalContent>
|
||||
<ModalFooter>
|
||||
<button onClick={doClose}>Close</button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,9 +1,20 @@
|
|||
import { h, Ref } from 'preact';
|
||||
|
||||
/**
|
||||
* Screen properties
|
||||
*/
|
||||
export interface ScreenProps {
|
||||
screen: Ref<HTMLCanvasElement>
|
||||
}
|
||||
|
||||
/**
|
||||
* Styled canvas element that the Apple II display is
|
||||
* rendered to by VideoModes
|
||||
*
|
||||
* @param screen Canvas element reference
|
||||
* @returns
|
||||
*/
|
||||
|
||||
export const Screen = ({ screen }: ScreenProps) => {
|
||||
return (
|
||||
<div id="display">
|
||||
|
|
26
js/components/hooks/useHotKey.ts
Normal file
26
js/components/hooks/useHotKey.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
import { useEffect } from 'preact/hooks';
|
||||
|
||||
/**
|
||||
* Hook to allow registering hotkey that will automatically
|
||||
* be cleaned up when they leave scope
|
||||
*
|
||||
* @param key KeyboardEvent key value to match
|
||||
* @param callback Invoked when key is pressed
|
||||
*/
|
||||
|
||||
export const useHotKey = (key: string, callback: (ev: KeyboardEvent) => void) => {
|
||||
useEffect(() => {
|
||||
const onKeyDown = (ev: KeyboardEvent) => {
|
||||
if (ev.key === key) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
callback(ev);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [key, callback]);
|
||||
};
|
7
js/components/hooks/usePrefs.ts
Normal file
7
js/components/hooks/usePrefs.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import Prefs from 'js/prefs';
|
||||
|
||||
// Todo(whscullin): More robust preferences
|
||||
|
||||
const prefs = new Prefs();
|
||||
|
||||
export const usePrefs = () => prefs;
|
176
js/components/util/files.ts
Normal file
176
js/components/util/files.ts
Normal file
|
@ -0,0 +1,176 @@
|
|||
import { includes } from 'js/types';
|
||||
import { initGamepad } from 'js/ui/gamepad';
|
||||
import {
|
||||
DISK_FORMATS,
|
||||
DriveNumber,
|
||||
JSONDisk,
|
||||
NIBBLE_FORMATS
|
||||
} from 'js/formats/types';
|
||||
import DiskII from 'js/cards/disk2';
|
||||
|
||||
/**
|
||||
* Routine to split a legacy hash into parts for disk loading
|
||||
*
|
||||
* @returns an padded array for 1 based indexing
|
||||
*/
|
||||
|
||||
export const getHashParts = () => {
|
||||
const parts = window.location.hash.match(/^#([^|]*)\|?(.*)$/) || ['', '', ''];
|
||||
return ['', parts[1], parts[2]];
|
||||
};
|
||||
|
||||
/**
|
||||
* Update the location hash to reflect the current disk state, if possible
|
||||
*
|
||||
* @param parts a padded array with values starting at index 1
|
||||
*/
|
||||
|
||||
export const setHashParts = (parts: string[]) => {
|
||||
window.location.hash = `#${parts[1]}` + (parts[2] ? `|${parts[2]}` : '');
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 loadLocalFile = async (
|
||||
disk2: DiskII,
|
||||
number: DriveNumber,
|
||||
file: File,
|
||||
) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const fileReader = new FileReader();
|
||||
fileReader.onload = function () {
|
||||
const result = this.result as ArrayBuffer;
|
||||
const parts = file.name.split('.');
|
||||
const ext = parts.pop()!.toLowerCase();
|
||||
const name = parts.join('.');
|
||||
|
||||
if (includes(DISK_FORMATS, ext)) {
|
||||
if (result.byteLength >= 800 * 1024) {
|
||||
reject(`Unable to load ${name}`);
|
||||
} else {
|
||||
if (
|
||||
includes(NIBBLE_FORMATS, ext) &&
|
||||
disk2?.setBinary(number, name, ext, result)
|
||||
) {
|
||||
initGamepad();
|
||||
} else {
|
||||
reject(`Unable to load ${name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
resolve(true);
|
||||
};
|
||||
fileReader.readAsArrayBuffer(file);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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 loadJSON = async (disk2: DiskII, number: DriveNumber, url: string) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
fetch(url).then(function (response: Response) {
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
} else {
|
||||
throw new Error('Error loading: ' + response.statusText);
|
||||
}
|
||||
}).then(function (data: JSONDisk) {
|
||||
if (includes(DISK_FORMATS, data.type)) {
|
||||
disk2.setDisk(number, data);
|
||||
}
|
||||
initGamepad(data.gamepad);
|
||||
resolve(true);
|
||||
}).catch(function (error) {
|
||||
reject(error.message);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
disk2: DiskII,
|
||||
number: DriveNumber,
|
||||
url: string,
|
||||
) => {
|
||||
if (url.endsWith('.json')) {
|
||||
return loadJSON(disk2, number, url);
|
||||
} else {
|
||||
return new Promise((resolve, reject) => {
|
||||
fetch(url).then(function (response: Response) {
|
||||
if (response.ok) {
|
||||
const reader = response!.body!.getReader();
|
||||
let received = 0;
|
||||
const chunks: Uint8Array[] = [];
|
||||
// const contentLength = parseInt(response.headers.get('content-length')!, 10);
|
||||
|
||||
return reader.read().then(
|
||||
function readChunk(result): Promise<ArrayBufferLike> {
|
||||
if (result.done) {
|
||||
const data = new Uint8Array(received);
|
||||
let offset = 0;
|
||||
for (let idx = 0; idx < chunks.length; idx++) {
|
||||
data.set(chunks[idx], offset);
|
||||
offset += chunks[idx].length;
|
||||
}
|
||||
return Promise.resolve(data.buffer);
|
||||
}
|
||||
|
||||
received += result.value.length;
|
||||
// if (contentLength) {
|
||||
// loadingProgress(received, contentLength);
|
||||
// }
|
||||
chunks.push(result.value);
|
||||
|
||||
return reader.read().then(readChunk);
|
||||
});
|
||||
} else {
|
||||
reject('Error loading: ' + response.statusText);
|
||||
}
|
||||
}).then(function (data: ArrayBufferLike) {
|
||||
const urlParts = url!.split('/');
|
||||
const file = urlParts.pop()!;
|
||||
const fileParts = file.split('.');
|
||||
const ext = fileParts.pop()!.toLowerCase();
|
||||
const name = decodeURIComponent(fileParts.join('.'));
|
||||
if (data.byteLength >= 800 * 1024) {
|
||||
reject(`Unable to load ${url}`);
|
||||
} else if (
|
||||
includes(NIBBLE_FORMATS, ext) &&
|
||||
disk2.setBinary(number, name, ext, data)
|
||||
) {
|
||||
initGamepad();
|
||||
} else {
|
||||
reject(`Extension ${ext} not recognized.`);
|
||||
}
|
||||
resolve(true);
|
||||
}).catch(reject);
|
||||
});
|
||||
}
|
||||
};
|
|
@ -2,6 +2,11 @@ import { JSX } from 'preact';
|
|||
import { byte, DeepMemberOf, KnownKeys } from '../../types';
|
||||
import { debug, toHex } from '../../util';
|
||||
|
||||
/**
|
||||
* Map of KeyboardEvent.keyCode to ASCII, for normal,
|
||||
* shifted and control states.
|
||||
*/
|
||||
|
||||
// keycode: [plain, ctrl, shift]
|
||||
export const keymap = {
|
||||
// Most of these won't happen
|
||||
|
@ -162,6 +167,10 @@ export const isUiKitKey = (k: string): k is KnownKeys<typeof uiKitMap> => {
|
|||
return k in uiKitMap;
|
||||
};
|
||||
|
||||
/**
|
||||
* Keyboard layout for the Apple ][ / ][+
|
||||
*/
|
||||
|
||||
export const keys2 = [
|
||||
[
|
||||
['1', '2', '3', '4', '5', '6', '7', '8', '9', '0', ':', '-', 'RESET'],
|
||||
|
@ -180,6 +189,10 @@ export const keys2 = [
|
|||
|
||||
export type Key2 = DeepMemberOf<typeof keys2>;
|
||||
|
||||
/**
|
||||
* Keyboard layout for the Apple //e
|
||||
*/
|
||||
|
||||
export const keys2e = [
|
||||
[
|
||||
['ESC', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '-', '=', 'DELETE'],
|
||||
|
@ -202,6 +215,15 @@ export type Key = Key2 | Key2e;
|
|||
|
||||
export type KeyFunction = (key: KeyboardEvent) => void
|
||||
|
||||
/**
|
||||
* Convert a DOM keyboard event into an ASCII equivalent that
|
||||
* an Apple // can recognize.
|
||||
*
|
||||
* @param evt Event to convert
|
||||
* @param caps Caps Lock state
|
||||
* @returns ASCII character
|
||||
*/
|
||||
|
||||
export const mapKeyEvent = (evt: KeyboardEvent, caps: boolean) => {
|
||||
const code = evt.keyCode;
|
||||
let key: byte = 0xff;
|
||||
|
@ -221,6 +243,17 @@ export const mapKeyEvent = (evt: KeyboardEvent, caps: boolean) => {
|
|||
return key;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert a mouse event into an ASCII character based on the
|
||||
* data attached to the target DOM element.
|
||||
*
|
||||
* @param event Event to convert
|
||||
* @param shifted Shift key state
|
||||
* @param controlled Control key state
|
||||
* @param caps Caps Lock state
|
||||
* @param e //e status
|
||||
* @returns ASCII character
|
||||
*/
|
||||
export const mapMouseEvent = (
|
||||
event: JSX.TargetedMouseEvent<HTMLElement>,
|
||||
shifted: boolean,
|
||||
|
@ -281,3 +314,25 @@ export const mapMouseEvent = (
|
|||
}
|
||||
return { keyCode, key, keyLabel };
|
||||
};
|
||||
|
||||
/**
|
||||
* Remap keys so that upper and lower are a tuple per row instead of
|
||||
* separate rows
|
||||
*
|
||||
* @param inKeys keys2 or keys2e
|
||||
* @returns Keys remapped
|
||||
*/
|
||||
|
||||
export const keysAsTuples = (inKeys: typeof keys2e | typeof keys2): string[][][] => {
|
||||
const rows = [];
|
||||
for (let idx = 0; idx < inKeys[0].length; idx++) {
|
||||
const upper = inKeys[0][idx];
|
||||
const lower = inKeys[1][idx];
|
||||
const keys = [];
|
||||
for (let jdx = 0; jdx < upper.length; jdx++) {
|
||||
keys.push([upper[jdx], lower[jdx]]);
|
||||
}
|
||||
rows.push(keys);
|
||||
}
|
||||
return rows;
|
||||
};
|
||||
|
|
71
js/components/util/systems.ts
Normal file
71
js/components/util/systems.ts
Normal file
|
@ -0,0 +1,71 @@
|
|||
export interface SystemType {
|
||||
rom: string
|
||||
characterRom: string
|
||||
e: boolean
|
||||
enhanced: boolean
|
||||
sectors: 13 | 16
|
||||
}
|
||||
|
||||
// Enhanced Apple //e
|
||||
|
||||
export const defaultSystem = {
|
||||
rom: 'apple2enh',
|
||||
characterRom: 'apple2enh_char',
|
||||
e: true,
|
||||
enhanced: true,
|
||||
sectors: 16,
|
||||
};
|
||||
|
||||
export const systemTypes: Record<string, Partial<SystemType>> = {
|
||||
// Apple //e
|
||||
apple2e: {
|
||||
rom: 'apple2e',
|
||||
characterRom: 'apple2e_char',
|
||||
enhanced: false
|
||||
},
|
||||
apple2rm: {
|
||||
characterRom: 'rmfont_char',
|
||||
},
|
||||
apple2ex: {
|
||||
rom: 'apple2ex',
|
||||
e: false,
|
||||
},
|
||||
|
||||
// Apple ][s
|
||||
apple2: {
|
||||
rom: 'intbasic',
|
||||
characterRom: 'apple2_char',
|
||||
e: false,
|
||||
},
|
||||
apple213: {
|
||||
rom: 'intbasic',
|
||||
characterRom: 'apple2_char',
|
||||
e: false,
|
||||
sectors: 13,
|
||||
},
|
||||
original: {
|
||||
rom: 'original',
|
||||
characterRom: 'apple2_char',
|
||||
e: false,
|
||||
},
|
||||
apple2jplus: {
|
||||
rom: 'apple2j',
|
||||
characterRom: 'apple2j_char',
|
||||
e: false,
|
||||
},
|
||||
apple2pig: {
|
||||
rom: 'fpbasic',
|
||||
characterRom: 'pigfont_char',
|
||||
e: false,
|
||||
},
|
||||
apple2lc:{
|
||||
rom: 'fpbasic',
|
||||
characterRom: 'apple2lc_char',
|
||||
e: false,
|
||||
},
|
||||
apple2plus: {
|
||||
rom: 'fpbasic',
|
||||
characterRom: 'apple2_char',
|
||||
e: false,
|
||||
}
|
||||
};
|
|
@ -1,4 +1,4 @@
|
|||
import type { byte, memory, MemberOf } from '../types';
|
||||
import type { byte, memory, MemberOf, word } from '../types';
|
||||
import type { GamepadConfiguration } from '../ui/types';
|
||||
|
||||
export const DRIVE_NUMBERS = [1, 2] as const;
|
||||
|
@ -18,6 +18,29 @@ export interface DiskOptions {
|
|||
blockVolume?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON file entry format
|
||||
*/
|
||||
export interface DiskDescriptor {
|
||||
name: string;
|
||||
disk?: number;
|
||||
filename: string;
|
||||
e?: boolean;
|
||||
category: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON binary image (not used?)
|
||||
*/
|
||||
|
||||
export interface JSONBinaryImage {
|
||||
type: 'binary',
|
||||
start: word,
|
||||
length: word,
|
||||
data: byte[],
|
||||
gamepad?: GamepadConfiguration,
|
||||
}
|
||||
|
||||
/**
|
||||
* Return value from disk format processors. Describes raw disk
|
||||
* data which the DiskII card can process.
|
||||
|
|
94
js/options.ts
Normal file
94
js/options.ts
Normal file
|
@ -0,0 +1,94 @@
|
|||
import Prefs from './prefs';
|
||||
|
||||
export const BOOLEAN_OPTION = 'BOOLEAN_OPTION';
|
||||
export const SELECT_OPTION = 'SELECT_OPTION';
|
||||
|
||||
export interface Option {
|
||||
name: string
|
||||
label: string
|
||||
type: string
|
||||
defaultVal: string | boolean
|
||||
}
|
||||
|
||||
export interface BooleanOption extends Option {
|
||||
type: typeof BOOLEAN_OPTION
|
||||
defaultVal: boolean
|
||||
}
|
||||
|
||||
export interface SelectOption extends Option {
|
||||
type: typeof SELECT_OPTION
|
||||
defaultVal: string
|
||||
values: Array<{name: string, value: string}>
|
||||
}
|
||||
|
||||
export interface OptionSection {
|
||||
name: string
|
||||
options: Option[]
|
||||
}
|
||||
|
||||
export interface OptionHandler {
|
||||
getOptions: () => OptionSection[]
|
||||
setOption: (name: string, value: string | boolean) => void
|
||||
}
|
||||
|
||||
export class Options {
|
||||
private prefs: Prefs = new Prefs();
|
||||
private options: Record<string, Option> = {};
|
||||
private handlers: Record<string, OptionHandler> = {};
|
||||
private sections: OptionSection[] = [];
|
||||
|
||||
addOptions(handler: OptionHandler) {
|
||||
const sections = handler.getOptions();
|
||||
for (const section of sections) {
|
||||
const { options } = section;
|
||||
for (const option of options) {
|
||||
const { name } = option;
|
||||
this.handlers[name] = handler;
|
||||
this.options[name] = option;
|
||||
const value = this.getOption(name);
|
||||
if (value != null) {
|
||||
handler.setOption(name, value);
|
||||
}
|
||||
}
|
||||
this.sections.push(section);
|
||||
}
|
||||
}
|
||||
|
||||
getOption(name: string): string | boolean | undefined {
|
||||
const option = this.options[name];
|
||||
if (option) {
|
||||
const { name, defaultVal, type } = option;
|
||||
const stringVal = String(defaultVal);
|
||||
const prefVal = this.prefs.readPref(name, stringVal);
|
||||
switch (type) {
|
||||
case BOOLEAN_OPTION:
|
||||
return prefVal === 'true';
|
||||
default:
|
||||
return prefVal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setOption(name: string, value: string | boolean) {
|
||||
if (name in this.options) {
|
||||
const handler = this.handlers[name];
|
||||
const option = this.options[name];
|
||||
this.prefs.writePref(name, String(value));
|
||||
switch (option.type) {
|
||||
case BOOLEAN_OPTION:
|
||||
handler.setOption(name, Boolean(value));
|
||||
break;
|
||||
default:
|
||||
handler.setOption(name, String(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getOptions() {
|
||||
return this.options;
|
||||
}
|
||||
|
||||
getSections() {
|
||||
return this.sections;
|
||||
}
|
||||
}
|
19
js/prefs.ts
19
js/prefs.ts
|
@ -1,10 +1,12 @@
|
|||
const havePrefs = typeof window.localStorage !== 'undefined';
|
||||
|
||||
export default class Prefs {
|
||||
params: URLSearchParams;
|
||||
url: URL;
|
||||
title: string;
|
||||
|
||||
constructor() {
|
||||
this.params = new URLSearchParams(window.location.search);
|
||||
this.url = new URL(window.location.href);
|
||||
this.title = window.document.title;
|
||||
}
|
||||
|
||||
havePrefs() {
|
||||
|
@ -14,8 +16,8 @@ export default class Prefs {
|
|||
readPref(name: string): string | null
|
||||
readPref(name: string, defaultValue: string): string
|
||||
readPref(name: string, defaultValue: string | null = null) {
|
||||
if (this.params.has(name)) {
|
||||
return this.params.get(name);
|
||||
if (this.url.searchParams.has(name)) {
|
||||
return this.url.searchParams.get(name);
|
||||
}
|
||||
|
||||
if (havePrefs) {
|
||||
|
@ -25,6 +27,15 @@ export default class Prefs {
|
|||
}
|
||||
|
||||
writePref(name: string, value: string) {
|
||||
if (this.url.searchParams.has(name)) {
|
||||
this.url.searchParams.set(name, value);
|
||||
history.replaceState(
|
||||
null,
|
||||
this.title,
|
||||
this.url.toString()
|
||||
);
|
||||
}
|
||||
|
||||
if (havePrefs) {
|
||||
window.localStorage.setItem(name, value);
|
||||
}
|
||||
|
|
|
@ -3,18 +3,21 @@ import MicroModal from 'micromodal';
|
|||
import { base64_json_parse, base64_json_stringify } from '../base64';
|
||||
import { Audio, SOUND_ENABLED_OPTION } from './audio';
|
||||
import DriveLights from './drive_lights';
|
||||
import { byte, includes, word } from '../types';
|
||||
import { BLOCK_FORMATS, MassStorage, NIBBLE_FORMATS } from '../formats/types';
|
||||
import { includes, word } from '../types';
|
||||
import {
|
||||
BLOCK_FORMATS,
|
||||
DISK_FORMATS,
|
||||
DiskDescriptor,
|
||||
DriveNumber,
|
||||
DRIVE_NUMBERS,
|
||||
MassStorage,
|
||||
NIBBLE_FORMATS,
|
||||
JSONBinaryImage,
|
||||
JSONDisk
|
||||
} from '../formats/types';
|
||||
import { initGamepad } from './gamepad';
|
||||
import KeyBoard from './keyboard';
|
||||
import Tape, { TAPE_TYPES } from './tape';
|
||||
import type { GamepadConfiguration } from './types';
|
||||
|
||||
import ApplesoftDump from '../applesoft/decompiler';
|
||||
import ApplesoftCompiler from '../applesoft/compiler';
|
||||
|
@ -31,6 +34,7 @@ import { OptionsModal } from './options_modal';
|
|||
import { Screen, SCREEN_FULL_PAGE } from './screen';
|
||||
import { JoyStick } from './joystick';
|
||||
import { System } from './system';
|
||||
import { Options } from '../options';
|
||||
|
||||
let paused = false;
|
||||
|
||||
|
@ -41,15 +45,8 @@ let lastRenderedFrames = 0;
|
|||
|
||||
let hashtag = document.location.hash;
|
||||
|
||||
const optionsModal = new OptionsModal();
|
||||
|
||||
interface DiskDescriptor {
|
||||
name: string;
|
||||
disk?: number;
|
||||
filename: string;
|
||||
e?: boolean;
|
||||
category: string;
|
||||
}
|
||||
const options = new Options();
|
||||
const optionsModal = new OptionsModal(options);
|
||||
|
||||
type DiskCollection = {
|
||||
[name: string]: DiskDescriptor[]
|
||||
|
@ -228,14 +225,6 @@ function loadingStop() {
|
|||
}
|
||||
}
|
||||
|
||||
interface JSONBinaryImage {
|
||||
type: 'binary',
|
||||
start: word,
|
||||
length: word,
|
||||
data: byte[],
|
||||
gamepad?: GamepadConfiguration,
|
||||
}
|
||||
|
||||
export function loadAjax(drive: DriveNumber, url: string) {
|
||||
loadingStart();
|
||||
|
||||
|
@ -538,7 +527,7 @@ export function toggleShowFPS() {
|
|||
|
||||
export function toggleSound() {
|
||||
const on = !audio.isEnabled();
|
||||
optionsModal.setOption(SOUND_ENABLED_OPTION, on);
|
||||
options.setOption(SOUND_ENABLED_OPTION, on);
|
||||
updateSoundButton(on);
|
||||
}
|
||||
|
||||
|
@ -866,16 +855,16 @@ function onLoaded(apple2: Apple2, disk2: DiskII, massStorage: MassStorage, print
|
|||
_e = e;
|
||||
|
||||
system = new System(io, e);
|
||||
optionsModal.addOptions(system);
|
||||
options.addOptions(system);
|
||||
|
||||
joystick = new JoyStick(io);
|
||||
optionsModal.addOptions(joystick);
|
||||
options.addOptions(joystick);
|
||||
|
||||
screen = new Screen(vm);
|
||||
optionsModal.addOptions(screen);
|
||||
options.addOptions(screen);
|
||||
|
||||
audio = new Audio(io);
|
||||
optionsModal.addOptions(audio);
|
||||
options.addOptions(audio);
|
||||
initSoundToggle();
|
||||
|
||||
ready = Promise.all([audio.ready, apple2.ready]);
|
||||
|
@ -887,9 +876,9 @@ function onLoaded(apple2: Apple2, disk2: DiskII, massStorage: MassStorage, print
|
|||
keyboard.setFunction('F1', () => cpu.reset());
|
||||
keyboard.setFunction('F2', (event) => {
|
||||
if (event.shiftKey) { // Full window, but not full screen
|
||||
optionsModal.setOption(
|
||||
options.setOption(
|
||||
SCREEN_FULL_PAGE,
|
||||
!optionsModal.getOption(SCREEN_FULL_PAGE)
|
||||
!options.getOption(SCREEN_FULL_PAGE)
|
||||
);
|
||||
} else {
|
||||
screen.enterFullScreen();
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { BOOLEAN_OPTION, OptionHandler } from './options_modal';
|
||||
import { BOOLEAN_OPTION, OptionHandler } from '../options';
|
||||
import Apple2IO from '../apple2io';
|
||||
import { debug } from '../util';
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import Apple2IO from '../apple2io';
|
||||
import { BOOLEAN_OPTION, OptionHandler } from './options_modal';
|
||||
import { BOOLEAN_OPTION, OptionHandler } from '../options';
|
||||
|
||||
const JOYSTICK_DISABLE = 'disable_mouse';
|
||||
const JOYSTICK_FLIP_X_AXIS = 'flip_x';
|
||||
|
|
|
@ -1,103 +1,19 @@
|
|||
import Prefs from '../prefs';
|
||||
import MicroModal from 'micromodal';
|
||||
|
||||
export const BOOLEAN_OPTION = 'BOOLEAN_OPTION';
|
||||
export const SELECT_OPTION = 'SELECT_OPTION';
|
||||
|
||||
export interface Option {
|
||||
name: string
|
||||
label: string
|
||||
type: string
|
||||
defaultVal: string | boolean
|
||||
}
|
||||
|
||||
export interface BooleanOption extends Option {
|
||||
type: typeof BOOLEAN_OPTION
|
||||
defaultVal: boolean
|
||||
}
|
||||
|
||||
export interface SelectOption extends Option {
|
||||
type: typeof SELECT_OPTION
|
||||
defaultVal: string
|
||||
values: Array<{name: string, value: string}>
|
||||
}
|
||||
|
||||
export interface OptionSection {
|
||||
name: string
|
||||
options: Option[]
|
||||
}
|
||||
|
||||
export interface OptionHandler {
|
||||
getOptions: () => OptionSection[]
|
||||
setOption: (name: string, value: string | boolean) => void
|
||||
}
|
||||
import {
|
||||
BOOLEAN_OPTION,
|
||||
SELECT_OPTION,
|
||||
Options,
|
||||
SelectOption
|
||||
} from '../options';
|
||||
|
||||
export class OptionsModal {
|
||||
private prefs: Prefs = new Prefs();
|
||||
private options: Record<string, Option> = {};
|
||||
private handlers: Record<string, OptionHandler> = {};
|
||||
private sections: OptionSection[] = [];
|
||||
|
||||
addOptions(handler: OptionHandler) {
|
||||
const sections = handler.getOptions();
|
||||
for (const section of sections) {
|
||||
const { options } = section;
|
||||
for (const option of options) {
|
||||
const { name } = option;
|
||||
this.handlers[name] = handler;
|
||||
this.options[name] = option;
|
||||
const value = this.getOption(name);
|
||||
if (value != null) {
|
||||
handler.setOption(name, value);
|
||||
}
|
||||
}
|
||||
this.sections.push(section);
|
||||
}
|
||||
}
|
||||
|
||||
getOption(name: string): string | boolean | undefined {
|
||||
const option = this.options[name];
|
||||
if (option) {
|
||||
const { name, defaultVal, type } = option;
|
||||
const stringVal = String(defaultVal);
|
||||
const prefVal = this.prefs.readPref(name, stringVal);
|
||||
switch (type) {
|
||||
case BOOLEAN_OPTION:
|
||||
return prefVal === 'true';
|
||||
default:
|
||||
return prefVal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setOption(name: string, value: string | boolean) {
|
||||
if (name in this.options) {
|
||||
const handler = this.handlers[name];
|
||||
const option = this.options[name];
|
||||
this.prefs.writePref(name, String(value));
|
||||
switch (option.type) {
|
||||
case BOOLEAN_OPTION:
|
||||
handler.setOption(name, Boolean(value));
|
||||
break;
|
||||
default:
|
||||
handler.setOption(name, String(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getOptions() {
|
||||
return this.options;
|
||||
}
|
||||
|
||||
getSections() {
|
||||
return this.sections;
|
||||
}
|
||||
constructor(private options: Options) {}
|
||||
|
||||
openModal = () => {
|
||||
const content = document.querySelector('#options-modal-content');
|
||||
if (content) {
|
||||
content.innerHTML = '';
|
||||
for (const section of this.sections) {
|
||||
for (const section of this.options.getSections()) {
|
||||
const { name, options } = section;
|
||||
|
||||
// Section header
|
||||
|
@ -108,15 +24,15 @@ export class OptionsModal {
|
|||
// Preferences
|
||||
const list = document.createElement('ul');
|
||||
for (const option of options) {
|
||||
const { name, label, defaultVal, type } = option;
|
||||
const { name, label, type } = option;
|
||||
const onChange = (evt: InputEvent & { target: HTMLInputElement }) => {
|
||||
const { target } = evt;
|
||||
switch (type) {
|
||||
case BOOLEAN_OPTION:
|
||||
this.setOption(name, target.checked);
|
||||
this.options.setOption(name, target.checked);
|
||||
break;
|
||||
default:
|
||||
this.setOption(name, target.value);
|
||||
this.options.setOption(name, target.value);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -127,7 +43,7 @@ export class OptionsModal {
|
|||
case BOOLEAN_OPTION:
|
||||
{
|
||||
const inputElement = document.createElement('input');
|
||||
const checked = this.prefs.readPref(name, String(defaultVal)) === 'true';
|
||||
const checked = this.options.getOption(name) as boolean;
|
||||
inputElement.setAttribute('type', 'checkbox');
|
||||
inputElement.checked = checked;
|
||||
element = inputElement;
|
||||
|
@ -137,7 +53,7 @@ export class OptionsModal {
|
|||
{
|
||||
const selectOption = option as SelectOption;
|
||||
const selectElement = document.createElement('select');
|
||||
const selected = this.prefs.readPref(name, String(defaultVal));
|
||||
const selected = this.options.getOption(name) as string;
|
||||
for (const value of selectOption.values) {
|
||||
const optionElement = document.createElement('option');
|
||||
optionElement.value = value.value;
|
||||
|
@ -151,7 +67,7 @@ export class OptionsModal {
|
|||
default:
|
||||
{
|
||||
const inputElement = document.createElement('input');
|
||||
const value = this.prefs.readPref(name, String(defaultVal));
|
||||
const value = this.options.getOption(name) as string;
|
||||
inputElement.value = value;
|
||||
element = inputElement;
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { VideoModes } from '../videomodes';
|
||||
import { BOOLEAN_OPTION, OptionHandler } from './options_modal';
|
||||
import { BOOLEAN_OPTION, OptionHandler } from '../options';
|
||||
|
||||
export const SCREEN_MONO = 'mono_screen';
|
||||
export const SCREEN_FULL_PAGE = 'full_page';
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { BOOLEAN_OPTION, SELECT_OPTION, OptionHandler } from './options_modal';
|
||||
import { BOOLEAN_OPTION, SELECT_OPTION, OptionHandler } from '../options';
|
||||
import Apple2IO from '../apple2io';
|
||||
|
||||
const SYSTEM_TYPE_APPLE2E = 'computer_type2e';
|
||||
const SYSTEM_TYPE_APPLE2 = 'computer_type2';
|
||||
const SYSTEM_CPU_ACCELERATED = 'accelerator_toggle';
|
||||
export const SYSTEM_TYPE_APPLE2E = 'computer_type2e';
|
||||
export const SYSTEM_TYPE_APPLE2 = 'computer_type2';
|
||||
export const SYSTEM_CPU_ACCELERATED = 'accelerator_toggle';
|
||||
|
||||
export class System implements OptionHandler {
|
||||
constructor(private io: Apple2IO, private e: boolean) {}
|
||||
|
|
17
json/disks/index.json
Normal file
17
json/disks/index.json
Normal file
|
@ -0,0 +1,17 @@
|
|||
[
|
||||
{
|
||||
"filename": "json/disks/audit.json",
|
||||
"name": "Apple II Audit",
|
||||
"category": "Utility"
|
||||
},
|
||||
{
|
||||
"filename": "json/disks/dos33master.json",
|
||||
"name": "DOS 3.3 Master",
|
||||
"category": "System"
|
||||
},
|
||||
{
|
||||
"filename": "json/disks/prodos.json",
|
||||
"name": "ProDOS",
|
||||
"category": "System"
|
||||
}
|
||||
]
|
|
@ -1,11 +1,14 @@
|
|||
/** @jest-environment jsdom */
|
||||
import { screen } from '@testing-library/dom';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { Options } from 'js/options';
|
||||
|
||||
import {
|
||||
BOOLEAN_OPTION,
|
||||
SELECT_OPTION,
|
||||
OptionHandler,
|
||||
} from 'js/options';
|
||||
import {
|
||||
OptionsModal
|
||||
} from 'js/ui/options_modal';
|
||||
|
||||
|
@ -59,10 +62,12 @@ const mockOptionHandler: OptionHandler = {
|
|||
};
|
||||
|
||||
describe('OptionsModal', () => {
|
||||
let options: Options;
|
||||
let modal: OptionsModal;
|
||||
beforeEach(() => {
|
||||
modal = new OptionsModal();
|
||||
modal.addOptions(mockOptionHandler);
|
||||
options = new Options();
|
||||
options.addOptions(mockOptionHandler);
|
||||
modal = new OptionsModal(options);
|
||||
});
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
|
@ -107,31 +112,32 @@ describe('OptionsModal', () => {
|
|||
|
||||
describe('getOption', () => {
|
||||
beforeEach(() => {
|
||||
modal = new OptionsModal();
|
||||
modal.addOptions(mockOptionHandler);
|
||||
options = new Options();
|
||||
options.addOptions(mockOptionHandler);
|
||||
modal = new OptionsModal(options);
|
||||
});
|
||||
it('gets boolean', () => {
|
||||
expect(modal.getOption('option_1'))
|
||||
expect(options.getOption('option_1'))
|
||||
.toEqual(false);
|
||||
expect(modal.getOption('option_3'))
|
||||
expect(options.getOption('option_3'))
|
||||
.toEqual(true);
|
||||
});
|
||||
|
||||
it('gets selector', () => {
|
||||
expect(modal.getOption('option_2'))
|
||||
expect(options.getOption('option_2'))
|
||||
.toEqual('select_1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setOption', () => {
|
||||
it('sets boolean', () => {
|
||||
modal.setOption('option_1', true);
|
||||
options.setOption('option_1', true);
|
||||
expect(mockOptionHandler.setOption)
|
||||
.toHaveBeenCalledWith('option_1', true);
|
||||
});
|
||||
|
||||
it('sets selector', () => {
|
||||
modal.setOption('option_2', 'select_2');
|
||||
options.setOption('option_2', 'select_2');
|
||||
expect(mockOptionHandler.setOption)
|
||||
.toHaveBeenCalledWith('option_2', 'select_2');
|
||||
});
|
||||
|
|
|
@ -13,6 +13,7 @@
|
|||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"sourceMap": true,
|
||||
"strictNullChecks": true,
|
||||
"outDir": "dist",
|
||||
|
@ -26,7 +27,10 @@
|
|||
"js/*": [
|
||||
"js/*"
|
||||
],
|
||||
"test/*": [
|
||||
"json/*": [
|
||||
"json/*"
|
||||
],
|
||||
"test/*": [
|
||||
"test/*"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -25,6 +25,10 @@ const baseConfig = {
|
|||
},
|
||||
resolve: {
|
||||
extensions: ['.ts', '.tsx', '.js'],
|
||||
alias: {
|
||||
js: path.resolve(__dirname, 'js/'),
|
||||
json: path.resolve(__dirname, 'json/'),
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
|
Loading…
Reference in New Issue
Block a user