Preact UI (#106)

First pass at a Preact UI, still short some major features but full proof of concept.
This commit is contained in:
Will Scullin 2022-05-10 06:52:06 -07:00 committed by GitHub
parent 702089224f
commit 4a188a9a5c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
47 changed files with 2819 additions and 698 deletions

View File

@ -20,7 +20,8 @@
"prefer-const": [
"error"
],
"semi": [
"semi": "off",
"@typescript-eslint/semi": [
"error",
"always"
],
@ -52,7 +53,8 @@
"error"
]
}
]
],
"@typescript-eslint/require-await": ["error"]
},
"env": {
"builtin": true,
@ -120,5 +122,10 @@
}
}
],
"ignorePatterns": ["coverage/**/*"]
"ignorePatterns": ["coverage/**/*"],
"settings": {
"react": {
"pragma": "h"
}
}
}

View File

@ -9,5 +9,19 @@ module.exports = {
},
},
],
[
'@babel/typescript',
{
jsxPragma: 'h'
}
],
],
plugins: [
[
'@babel/plugin-transform-react-jsx', {
pragma: 'h',
pragmaFrag: 'Fragment',
}
]
]
};

View File

@ -122,6 +122,10 @@ body {
height: 16px;
}
.disk-light.on {
background-image: url(red-on-16.png);
}
.disk-label {
color: #000;
font-family: sans-serif;
@ -583,6 +587,7 @@ button:focus {
#reset-row .inset {
margin: 0;
width: 604px;
}
#reset {
@ -650,7 +655,6 @@ button:focus {
#options-modal {
width: 300px;
line-height: 1.75em;
}
#options-modal h3 {

10
index.html Normal file
View File

@ -0,0 +1,10 @@
<!DOCTYPE html>
<head>
<title>PreApple II</title>
<link rel="stylesheet" type="text/css" href="css/apple2.css" />
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.0.2/css/all.css" />
</head>
<body>
<div id="app"></div>
<script src="dist/preact.bundle.js"></script>
</body>

View File

@ -33,25 +33,26 @@ import { processGamepad } from './ui/gamepad';
export interface Apple2Options {
characterRom: string;
enhanced: boolean,
e: boolean,
gl: boolean,
rom: string,
canvas: HTMLCanvasElement,
tick: () => void,
enhanced: boolean;
e: boolean;
gl: boolean;
rom: string;
canvas: HTMLCanvasElement;
tick: () => void;
}
export interface Stats {
frames: number,
renderedFrames: number,
cycles: number;
frames: number;
renderedFrames: number;
}
interface State {
cpu: CpuState,
vm: VideoModesState,
io: Apple2IOState,
mmu?: MMUState,
ram?: RAMState[],
cpu: CpuState;
vm: VideoModesState;
io: Apple2IOState;
mmu?: MMUState;
ram?: RAMState[];
}
export class Apple2 implements Restorable<State>, DebuggerContainer {
@ -78,6 +79,7 @@ export class Apple2 implements Restorable<State>, DebuggerContainer {
private tick: () => void;
private stats: Stats = {
cycles: 0,
frames: 0,
renderedFrames: 0
};
@ -186,6 +188,7 @@ export class Apple2 implements Restorable<State>, DebuggerContainer {
this.stats.renderedFrames++;
}
}
this.stats.cycles = this.cpu.getCycles();
this.stats.frames++;
this.io.tick();
this.tick();

35
js/components/App.tsx Normal file
View File

@ -0,0 +1,35 @@
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 e={system.e} />
<Apple2
gl={gl}
{...system}
/>
</>
);
};

69
js/components/Apple2.tsx Normal file
View File

@ -0,0 +1,69 @@
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';
import { ControlStrip } from './ControlStrip';
import { Inset } from './Inset';
import { Keyboard } from './Keyboard';
import { Screen } from './Screen';
import { Drives } from './Drives';
/**
* Interface for the Apple2 component.
*/
export interface Apple2Props {
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>();
useEffect(() => {
if (screen.current) {
const options = {
canvas: screen.current,
tick: () => {},
...props,
};
const apple2 = new Apple2Impl(options);
setApple2(apple2);
apple2.ready.then(() => {
const io = apple2.getIO();
setIO(io);
apple2.getCPU().reset();
apple2.run();
}).catch(error => console.error(error));
}
}, []);
return (
<div className={cs('outer', { apple2e: e})}>
<Screen screen={screen} />
<Inset>
<Drives io={io} sectors={sectors} />
</Inset>
<ControlStrip apple2={apple2} e={e} />
<Inset>
<Keyboard apple2={apple2} e={e} />
</Inset>
</div>
);
};

View File

@ -0,0 +1,72 @@
import { h } from 'preact';
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,
renderedFrames: 0,
cycles: 0,
});
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 - cycles) / delta
)
);
setFps(
Math.floor(
(stats.frames - frames) / delta * 1000
)
);
setRps(
Math.floor(
(stats.renderedFrames - renderedFrames) / delta * 1000
)
);
lastStats.current = { ...stats };
lastTime.current = time;
}
}, 1000);
return () => clearInterval(interval);
}, [apple2]);
const onClick = useCallback(() => {
setMode((mode) => (mode + 1) % 3);
}, []);
return (
<div id="khz" onClick={onClick}>
{mode === 0 && `${khz} Khz`}
{mode === 1 && `${fps} fps`}
{mode === 2 && `${rps} rps`}
</div>
);
};

View File

@ -0,0 +1,24 @@
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>
);

View File

@ -0,0 +1,138 @@
import { h } from 'preact';
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, 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';
interface ControlStripProps {
apple2: Apple2Impl | undefined;
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(() => {
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]);
const doPause = useCallback(() => {
apple2?.stop();
setRunning(false);
}, [apple2]);
const doRun = useCallback(() => {
apple2?.run();
setRunning(true);
}, [apple2]);
const doToggleSound = useCallback(() => {
const on = !audio?.isEnabled();
options.setOption(SOUND_ENABLED_OPTION, on);
setAudioEnabled(on);
}, [audio]);
const doReset = useCallback(() =>
apple2?.reset()
, [apple2]);
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 ? (
<ControlButton
onClick={doPause}
title="Pause"
icon="pause"
/>
) : (
<ControlButton
onClick={doRun}
title="Run"
icon="play"
/>
)}
<ControlButton
onClick={doToggleSound}
title="Toggle Sound"
icon={audioEnabled ? 'volume-up' : 'volume-off'}
/>
<div style={{flexGrow: 1}} />
<ControlButton onClick={doReadme} title="About" icon="info" />
<ControlButton onClick={doShowOptions} title="Options (F4)" icon="cog" />
</Inset>
{e && (
<button id="reset" onClick={doReset}>
Reset
</button>
)}
</div>
);
};

87
js/components/DiskII.tsx Normal file
View File

@ -0,0 +1,87 @@
import { h } from 'preact';
import { useCallback, useEffect, useState } from 'preact/hooks';
import cs from 'classnames';
import Disk2 from '../cards/disk2';
import { FileModal } from './FileModal';
import { loadJSON, loadHttpFile, getHashParts } from './util/files';
/**
* Storage structure for Disk II state returned via callbacks.
*/
export interface DiskIIData {
number: 1 | 2;
on: boolean;
name?: string;
side?: string;
}
/**
* Interface for Disk II component.
*/
export interface DiskIIProps extends DiskIIData {
disk2: Disk2 | undefined;
}
/**
* Disk II component
*
* 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 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);
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 doClose = useCallback(() => {
setModalOpen(false);
}, []);
const onOpenModal = useCallback(() => {
setModalOpen(true);
}, []);
return (
<div className="disk">
<FileModal disk2={disk2} number={number} onClose={doClose} isOpen={modalOpen} />
<div
id={`disk${number}`}
className={cs('disk-light', { on })}
/>
<button title="Load Disk">
<i class="fas fa-folder-open" onClick={onOpenModal} />
</button>
<div
id={`disk-label${number}`}
className="disk-label"
>
{label}
</div>
</div>
);
};

53
js/components/Drives.tsx Normal file
View File

@ -0,0 +1,53 @@
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 | undefined;
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} />
</>
);
};

123
js/components/FileModal.tsx Normal file
View File

@ -0,0 +1,123 @@
import { h, JSX } from 'preact';
import { useCallback, useRef, useState } from 'preact/hooks';
import { DiskDescriptor, DriveNumber, NibbleFormat } from '../formats/types';
import { Modal, ModalContent, ModalFooter } from './Modal';
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 {
isOpen: boolean;
disk2: DiskII | undefined;
number: DriveNumber;
onClose: (closeBox?: boolean) => void;
}
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 doCancel = useCallback(() => onClose(true), []);
const doOpen = useCallback(() => {
const hashParts = getHashParts();
if (disk2 && inputRef.current && inputRef.current.files?.length === 1) {
hashParts[number] = '';
setBusy(true);
loadLocalFile(disk2, number, inputRef.current.files[0])
.catch(console.error)
.finally(() => {
setBusy(false);
onClose();
});
}
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) {
setEmpty(!inputRef.current?.files?.length);
}
}, [ 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" 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={doCancel}>Cancel</button>
<button onClick={doOpen} disabled={busy || empty}>Open</button>
</ModalFooter>
</Modal>
);
};

24
js/components/Header.tsx Normal file
View File

@ -0,0 +1,24 @@
import { h } from 'preact';
/**
* 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 {e ? '//e' : ']['} Emulator in JavaScript</div>
</div>
);
};

12
js/components/Inset.tsx Normal file
View File

@ -0,0 +1,12 @@
import { h, FunctionalComponent } from 'preact';
/**
* Convenience component for a nice beveled border.
*
* @returns Inset component
*/
export const Inset: FunctionalComponent = ({ children }) => (
<div className="inset">
{children}
</div>
);

222
js/components/Keyboard.tsx Normal file
View File

@ -0,0 +1,222 @@
import { h, Fragment, JSX } from 'preact';
import cs from 'classnames';
import { useCallback, useEffect, useMemo, useState } from 'preact/hooks';
import { Apple2 as Apple2Impl } from '../apple2';
import {
keys2,
keys2e,
mapKeyEvent,
mapMouseEvent,
keysAsTuples
} from './util/keyboard';
/**
* 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={cs({ small })}
dangerouslySetInnerHTML={{ __html: key }}
/>
);
};
/**
* Key properties
*/
interface KeyProps {
lower: string;
upper: string;
active: boolean;
pressed: boolean;
onMouseDown: (event: MouseEvent) => void;
onMouseUp: (event: MouseEvent) => void;
}
/**
* 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 > 1 ?
'v-center'
: ''
);
return (
<div
className={cs(
'key',
`key-${keyName}`,
center,
{ pressed, active },
)}
data-key1={lower}
data-key2={upper}
onMouseDown={onMouseDown}
onMouseUp={onMouseUp}
>
<div>
{buildLabel(upper)}
{upper !== lower && <><br />{buildLabel(lower)}</>}
</div>
</div>
);
};
/**
* 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 {
apple2?.getIO().keyDown(key);
}
}
};
const keyUp = () => {
apple2?.getIO().keyUp();
};
document.addEventListener('keydown', keyDown);
document.addEventListener('keyup', keyUp);
return () => {
document.removeEventListener('keydown', keyDown);
document.removeEventListener('keyup', keyUp);
};
}, [apple2, active]);
const onMouseDown = useCallback(
(event: JSX.TargetedMouseEvent<HTMLElement>) => {
if (!apple2) {
return;
}
const toggleActive = (key: string) => {
if (!active.includes(key)) {
setActive([...active, key]);
} else {
setActive(active.filter(x => x !== key));
}
};
const io = apple2.getIO();
const { keyCode, key, keyLabel } = mapMouseEvent(
event,
active.includes('SHIFT'),
active.includes('CTRL'),
active.includes('LOCK'),
true
);
if (keyCode !== 0xff) {
io.keyDown(keyCode);
} else if (key) {
switch (key) {
case 'SHIFT':
case 'CTRL':
case 'LOCK':
toggleActive(key);
break;
case 'RESET':
apple2.reset();
break;
case 'OPEN_APPLE':
io.ioSwitch(0, io.ioSwitch(0) ? 0 : 1);
toggleActive(key);
break;
case 'CLOSED_APPLE':
io.ioSwitch(1, io.ioSwitch(1) ? 0 : 1);
toggleActive(key);
break;
default:
break;
}
}
setPressed([...pressed, 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]) =>
<Key
lower={lower}
upper={upper}
active={active.includes(lower)}
pressed={pressed.includes(upper)}
onMouseDown={onMouseDown}
onMouseUp={onMouseUp}
/>;
const rows = keys.map((row, idx) =>
<div className={`row row${idx}`}>
{row.map(bindKey)}
</div>
);
return (
<div id="keyboard" style={{ marginLeft: e ? 0 : 15 }}>
{rows}
</div>
);
};

188
js/components/Modal.tsx Normal file
View File

@ -0,0 +1,188 @@
import { h, FunctionalComponent } from 'preact';
import { useCallback } from 'preact/hooks';
import { useHotKey } from './hooks/useHotKey';
/**
* Temporary JS styling while I figure out how I really want
* to do it.
*/
const modalOverlayStyle = {
position: 'fixed',
left: '0',
right: '0',
top: '0',
bottom: '0',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(0,0,0,0.6)',
zIndex: 1,
};
const modalStyle = {
backgroundColor: '#c4c1a0',
padding: '10px',
maxHeight: '100vh',
borderRadius: '4px',
overflowY: 'auto',
boxSizing: 'border-box',
};
const modalHeaderStyle = {
display: 'flex',
fontSize: '14px',
justifyContent: 'space-between',
alignItems: 'center',
background: '#44372C',
color: '#fff',
padding: '5px 11px',
border: '1px outset #66594E',
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,
color: '#000'
};
const modalFooterStyle = {
textAlign: 'right'
};
/**
* ModalOverlay creates a semi-transparent overlay in which the
* modal is centered.
*
* @returns ModalOverlay component
*/
export const ModalOverlay: FunctionalComponent = ({ children }) => {
return (
<div style={modalOverlayStyle}>
{children}
</div>
);
};
/**
* ModalContent provides a styled container for modal content
*
* @returns ModalContent component
*/
export const ModalContent: FunctionalComponent = ({ children }) => {
return (
<div style={modalContentStyle}>
{children}
</div>
);
};
/**
* ModalFooter provides a right-aligned container for modal buttons.
*
* @returns ModalFooter component
*/
export const ModalFooter: FunctionalComponent = ({ children }) => {
return (
<div style={modalFooterStyle}>
{children}
</div>
);
};
/**
* ModalCloseButton component properties
*/
interface ModalCloseButtonProp {
onClose: (closeBox?: boolean) => void;
}
/**
* Renders a close button and registers a global Escape key
* hook to trigger it.
*
* @param onClose Close callback
* @returns ModalClose component
*/
export const ModalCloseButton = ({ onClose }: ModalCloseButtonProp) => {
const doClose = useCallback(() => onClose(true), [onClose]);
useHotKey('Escape', doClose);
return (
<button onClick={doClose} title="Close">
{'\u2715'}
</button>
);
};
/**
* 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 ModalHeader component
*/
export const ModalHeader = ({ onClose, title }: ModalHeaderProps) => {
return (
<div style={modalHeaderStyle}>
<span style={modalTitleStyle}>{title}</span>
{onClose && <ModalCloseButton onClose={onClose} />}
</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}>
{title && <ModalHeader onClose={onClose} title={title} />}
{children}
</div>
</ModalOverlay>
) : null
);
};

View File

@ -0,0 +1,7 @@
import { createContext } from 'preact';
import { Options } from '../options';
/**
* Context for getting, setting and configuring options
*/
export const OptionsContext = createContext(new Options());

View File

@ -0,0 +1,166 @@
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,
OptionSection,
SelectOption,
} from '../options';
/**
* Boolean property interface
*/
interface BooleanProps {
option: BooleanOption;
value: boolean;
setValue: (name: string, value: boolean) => void;
}
/**
*
* @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 (
<li>
<input
type="checkbox"
checked={value}
onChange={onChange}
/>
<label>{label}</label>
</li>
);
};
/**
* 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 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 (
<>
<h3>{section.name}</h3>
<ul>
{section.options.map(makeOption)}
</ul>
</>
);
};
const doClose = useCallback(() => onClose(), [onClose]);
return (
<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>
);
};

25
js/components/Screen.tsx Normal file
View File

@ -0,0 +1,25 @@
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">
<div class="overscan">
<canvas id="screen" width="592" height="416" ref={screen} />
</div>
</div>
);
};

View File

@ -0,0 +1,25 @@
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]);
};

View File

@ -0,0 +1,7 @@
import Prefs from 'js/prefs';
// Todo(whscullin): More robust preferences
const prefs = new Prefs();
export const usePrefs = () => prefs;

143
js/components/util/files.ts Normal file
View File

@ -0,0 +1,143 @@
import { includes } from 'js/types';
import { initGamepad } from 'js/ui/gamepad';
import {
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 = (
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() || '[none]';
const name = parts.join('.');
if (includes(NIBBLE_FORMATS, ext)) {
if (result.byteLength >= 800 * 1024) {
reject(`Unable to load ${name}`);
} else {
initGamepad();
if (disk2.setBinary(number, name, ext, result)) {
resolve(true);
} else {
reject(`Unable to load ${name}`);
}
}
} else {
reject(`Extension ${ext} not recognized.`);
}
};
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) => {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Error loading: ${response.statusText}`);
}
const data: JSONDisk = await response.json();
if (!includes(NIBBLE_FORMATS, data.type)) {
throw new Error(`Type ${data.type} not recognized.`);
}
disk2.setDisk(number, data);
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 (
disk2: DiskII,
number: DriveNumber,
url: string,
) => {
if (url.endsWith('.json')) {
return loadJSON(disk2, number, url);
}
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Error loading: ${response.statusText}`);
}
const reader = response.body!.getReader();
let received = 0;
const chunks: Uint8Array[] = [];
let result = await reader.read();
while (!result.done) {
chunks.push(result.value);
received += result.value.length;
result = await reader.read();
}
const data = new Uint8Array(received);
let offset = 0;
for (const chunk of chunks) {
data.set(chunk, offset);
offset += chunk.length;
}
const urlParts = url.split('/');
const file = urlParts.pop()!;
const fileParts = file.split('.');
const ext = fileParts.pop()?.toLowerCase() || '[none]';
const name = decodeURIComponent(fileParts.join('.'));
if (!includes(NIBBLE_FORMATS, ext)) {
throw new Error(`Extension ${ext} not recognized.`);
}
disk2.setBinary(number, name, ext, data);
initGamepad();
};

View File

@ -0,0 +1,334 @@
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
0x00: [0x00, 0x00, 0x00], //
0x01: [0x01, 0x01, 0x01], //
0x02: [0x02, 0x02, 0x02], //
0x03: [0x03, 0x03, 0x03], //
0x04: [0x04, 0x04, 0x04], //
0x05: [0x05, 0x05, 0x05], //
0x06: [0x06, 0x06, 0x06], //
0x07: [0x07, 0x07, 0x07], //
0x08: [0x7F, 0x7F, 0x7F], // BS/DELETE
0x09: [0x09, 0x09, 0x09], // TAB
0x0A: [0x0A, 0x0A, 0x0A], //
0x0B: [0x0B, 0x0B, 0x0B], //
0x0C: [0x0C, 0x0C, 0x0C], //
0x0D: [0x0D, 0x0D, 0x0D], // CR
0x0E: [0x0E, 0x0E, 0x0E], //
0x0F: [0x0F, 0x0F, 0x0F], //
0x10: [0xff, 0xff, 0xff], // SHIFT
0x11: [0xff, 0xff, 0xff], // CTRL
0x12: [0xff, 0xff, 0xff], // ALT/OPTION
0x13: [0x13, 0x13, 0x13], //
0x14: [0x14, 0x14, 0x14], //
0x15: [0x15, 0x15, 0x15], //
0x16: [0x16, 0x16, 0x16], //
0x17: [0x17, 0x17, 0x18], //
0x18: [0x18, 0x18, 0x18], //
0x19: [0x19, 0x19, 0x19], //
0x1A: [0x1A, 0x1A, 0x1A], //
0x1B: [0x1B, 0x1B, 0x1B], // ESC
0x1C: [0x1C, 0x1C, 0x1C], //
0x1D: [0x1D, 0x1D, 0x1D], //
0x1E: [0x1E, 0x1E, 0x1E], //
0x1F: [0x1F, 0x1F, 0x1F], //
// Most of these besides space won't happen
0x20: [0x20, 0x20, 0x20], //
0x21: [0x21, 0x21, 0x21], //
0x22: [0x22, 0x22, 0x22], //
0x23: [0x23, 0x23, 0x23], //
0x24: [0x24, 0x24, 0x24], //
0x25: [0x08, 0x08, 0x08], // <- left
0x26: [0x0B, 0x0B, 0x0B], // ^ up
0x27: [0x15, 0x15, 0x15], // -> right
0x28: [0x0A, 0x0A, 0x0A], // v down
0x29: [0x29, 0x29, 0x29], // )
0x2A: [0x2A, 0x2A, 0x2A], // *
0x2B: [0x2B, 0x2B, 0x2B], // +
0x2C: [0x2C, 0x2C, 0x3C], // , - <
0x2D: [0x2D, 0x2D, 0x5F], // - - _
0x2E: [0x2E, 0x2E, 0x3E], // . - >
0x2F: [0x2F, 0x2F, 0x3F], // / - ?
0x30: [0x30, 0x30, 0x29], // 0 - )
0x31: [0x31, 0x31, 0x21], // 1 - !
0x32: [0x32, 0x00, 0x40], // 2 - @
0x33: [0x33, 0x33, 0x23], // 3 - #
0x34: [0x34, 0x34, 0x24], // 4 - $
0x35: [0x35, 0x35, 0x25], // 5 - %
0x36: [0x36, 0x36, 0x5E], // 6 - ^
0x37: [0x37, 0x37, 0x26], // 7 - &
0x38: [0x38, 0x38, 0x2A], // 8 - *
0x39: [0x39, 0x39, 0x28], // 9 - (
0x3A: [0x3A, 0x3A, 0x3A], // :
0x3B: [0x3B, 0x3B, 0x3A], // ; - :
0x3C: [0x3C, 0x3C, 0x3C], // <
0x3D: [0x3D, 0x3D, 0x2B], // = - +
0x3E: [0x3E, 0x3E, 0x3E], // >
0x3F: [0x3F, 0x3F, 0x3F], // ?
// Alpha and control
0x40: [0x40, 0x00, 0x40], // @
0x41: [0x61, 0x01, 0x41], // A
0x42: [0x62, 0x02, 0x42], // B
0x43: [0x63, 0x03, 0x43], // C - BRK
0x44: [0x64, 0x04, 0x44], // D
0x45: [0x65, 0x05, 0x45], // E
0x46: [0x66, 0x06, 0x46], // F
0x47: [0x67, 0x07, 0x47], // G - BELL
0x48: [0x68, 0x08, 0x48], // H
0x49: [0x69, 0x09, 0x49], // I - TAB
0x4A: [0x6A, 0x0A, 0x4A], // J - NL
0x4B: [0x6B, 0x0B, 0x4B], // K - VT
0x4C: [0x6C, 0x0C, 0x4C], // L
0x4D: [0x6D, 0x0D, 0x4D], // M - CR
0x4E: [0x6E, 0x0E, 0x4E], // N
0x4F: [0x6F, 0x0F, 0x4F], // O
0x50: [0x70, 0x10, 0x50], // P
0x51: [0x71, 0x11, 0x51], // Q
0x52: [0x72, 0x12, 0x52], // R
0x53: [0x73, 0x13, 0x53], // S
0x54: [0x74, 0x14, 0x54], // T
0x55: [0x75, 0x15, 0x55], // U
0x56: [0x76, 0x16, 0x56], // V
0x57: [0x77, 0x17, 0x57], // W
0x58: [0x78, 0x18, 0x58], // X
0x59: [0x79, 0x19, 0x59], // Y
0x5A: [0x7A, 0x1A, 0x5A], // Z
0x5B: [0xFF, 0xFF, 0xFF], // Left window
0x5C: [0xFF, 0xFF, 0xFF], // Right window
0x5D: [0xFF, 0xFF, 0xFF], // Select
0x5E: [0x5E, 0x1E, 0x5E], //
0x5F: [0x5F, 0x1F, 0x5F], // _
// Numeric pad
0x60: [0x30, 0x30, 0x30], // 0
0x61: [0x31, 0x31, 0x31], // 1
0x62: [0x32, 0x32, 0x32], // 2
0x63: [0x33, 0x33, 0x33], // 3
0x64: [0x34, 0x34, 0x34], // 4
0x65: [0x35, 0x35, 0x35], // 5
0x66: [0x36, 0x36, 0x36], // 6
0x67: [0x37, 0x37, 0x37], // 7
0x68: [0x38, 0x38, 0x38], // 8
0x69: [0x39, 0x39, 0x39], // 9
0x6A: [0x2A, 0x2A, 0x2A], // *
0x6B: [0x2B, 0x2B, 0x2B], // +
0x6D: [0x2D, 0x2D, 0x2D], // -
0x6E: [0x2E, 0x2E, 0x2E], // .
0x6F: [0x2F, 0x2F, 0x39], // /
// Stray keys
0xAD: [0x2D, 0x2D, 0x5F], // - - _
0xBA: [0x3B, 0x3B, 0x3A], // ; - :
0xBB: [0x3D, 0x3D, 0x2B], // = - +
0xBC: [0x2C, 0x2C, 0x3C], // , - <
0xBD: [0x2D, 0x2D, 0x5F], // - - _
0xBE: [0x2E, 0x2E, 0x3E], // . - >
0xBF: [0x2F, 0x2F, 0x3F], // / - ?
0xC0: [0x60, 0x60, 0x7E], // ` - ~
0xDB: [0x5B, 0x1B, 0x7B], // [ - {
0xDC: [0x5C, 0x1C, 0x7C], // \ - |
0xDD: [0x5D, 0x1D, 0x7D], // ] - }
0xDE: [0x27, 0x22, 0x22], // ' - '
0xFF: [0xFF, 0xFF, 0xFF] // No comma line
} as const;
export const isKeyboardCode = (code: number): code is KnownKeys<typeof keymap> => {
return code in keymap;
};
const uiKitMap = {
'Dead': 0xFF,
'UIKeyInputLeftArrow': 0x08,
'UIKeyInputRightArrow': 0x15,
'UIKeyInputUpArrow': 0x0B,
'UIKeyInputDownArrow': 0x0A,
'UIKeyInputEscape': 0x1B
} as const;
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'],
['ESC', 'Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P', 'REPT', 'RETURN'],
['CTRL', 'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', ';', '&larr;', '&rarr;'],
['SHIFT', 'Z', 'X', 'C', 'V', 'B', 'N', 'M', ',', '.', '/', 'SHIFT'],
['POWER', '&nbsp;']
], [
['!', '"', '#', '$', '%', '&', '\'', '(', ')', '0', '*', '=', 'RESET'],
['ESC', 'Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', '@', 'REPT', 'RETURN'],
['CTRL', 'A', 'S', 'D', 'F', 'BELL', 'H', 'J', 'K', 'L', '+', '&larr;', '&rarr;'],
['SHIFT', 'Z', 'X', 'C', 'V', 'B', '^', ']', '<', '>', '?', 'SHIFT'],
['POWER', '&nbsp;']
]
] as const;
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'],
['TAB', 'Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P', '[', ']', '\\'],
['CTRL', 'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', ';', '"', 'RETURN'],
['SHIFT', 'Z', 'X', 'C', 'V', 'B', 'N', 'M', ',', '.', '/', 'SHIFT'],
['LOCK', '`', 'POW', 'OPEN_APPLE', '&nbsp;', 'CLOSED_APPLE', '&larr;', '&rarr;', '&darr;', '&uarr;']
], [
['ESC', '!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '_', '+', 'DELETE'],
['TAB', 'Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P', '{', '}', '|'],
['CTRL', 'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', ':', '\'', 'RETURN'],
['SHIFT', 'Z', 'X', 'C', 'V', 'B', 'N', 'M', '<', '>', '?', 'SHIFT'],
['CAPS', '~', 'POW', 'OPEN_APPLE', '&nbsp;', 'CLOSED_APPLE', '&larr;', '&rarr;', '&darr;', '&uarr;']
]
] as const;
export type Key2e = DeepMemberOf<typeof keys2e>;
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) => {
// TODO(whscullin): Find replacement for deprecated keycode
const code = evt.keyCode;
let key: byte = 0xff;
if (isUiKitKey(evt.key)) {
key = uiKitMap[evt.key];
} else if (isKeyboardCode(code)) {
key = keymap[code][evt.shiftKey ? 2 : (evt.ctrlKey ? 1 : 0)];
if (caps && key >= 0x61 && key <= 0x7A) {
key -= 0x20;
}
} else {
debug(`Unhandled key = ${toHex(code)}`);
}
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,
controlled: boolean,
caps: boolean,
e: boolean,
) => {
const keyLabel = event.currentTarget?.dataset.key2 ?? '';
let key = event.currentTarget?.dataset[shifted ? 'key2' : 'key1'] ?? '';
let keyCode = 0xff;
switch (key) {
case 'BELL':
key = 'G';
break;
case 'RETURN':
key = '\r';
break;
case 'TAB':
key = '\t';
break;
case 'DELETE':
key = '\x7F';
break;
case '&larr;':
key = '\x08';
break;
case '&rarr;':
key = '\x15';
break;
case '&darr;':
key = '\x0A';
break;
case '&uarr;':
key = '\x0B';
break;
case '&nbsp;':
key = ' ';
break;
case 'ESC':
key = '\x1B';
break;
default:
break;
}
if (key.length === 1) {
if (controlled && key >= '@' && key <= '_') {
keyCode = key.charCodeAt(0) - 0x40;
} else if (
e && !shifted && !caps &&
key >= 'A' && key <= 'Z'
) {
keyCode = key.charCodeAt(0) + 0x20;
} else {
keyCode = key.charCodeAt(0);
}
}
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;
};

View 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,
}
};

View File

@ -145,7 +145,7 @@ interface Opts {
type ReadFn = () => byte;
type WriteFn = (val: byte) => void;
type ReadAddrFn = (opts?: Opts) => word;
type ImpliedFn = () => void
type ImpliedFn = () => void;
interface Instruction<T = any> {
name: string
@ -161,9 +161,9 @@ type StrictInstruction =
Instruction<ImpliedFn> |
Instruction<flag> |
Instruction<flag|0> |
Instruction<byte>
Instruction<byte>;
type Instructions = Record<byte, StrictInstruction>
type Instructions = Record<byte, StrictInstruction>;
type callback = (cpu: CPU6502) => boolean | void;

View File

@ -10,7 +10,7 @@ export interface DebuggerContainer {
}
type symbols = { [key: number]: string };
type breakpointFn = (info: DebugInfo) => boolean
type breakpointFn = (info: DebugInfo) => boolean;
const alwaysBreak = (_info: DebugInfo) => { return true; };

4
js/entry.tsx Normal file
View File

@ -0,0 +1,4 @@
import { h, render } from 'preact';
import { App } from './components/App';
render(<App />, document.getElementById('app')!);

View File

@ -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.
@ -187,7 +210,7 @@ export interface DiskProcessedResponse {
}
export type FormatWorkerResponse =
DiskProcessedResponse
DiskProcessedResponse;
/**
* Block device common interface

94
js/options.ts Normal file
View 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;
}
}

View File

@ -1,21 +1,23 @@
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() {
return havePrefs;
}
readPref(name: string): string | null
readPref(name: string, defaultValue: string): string
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);
}

View File

@ -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);
}
@ -662,7 +651,7 @@ function updateLocalStorage() {
type LocalDiskIndex = {
[name: string]: string,
}
};
function saveLocalStorage(drive: DriveNumber, name: string) {
const diskIndex = JSON.parse(window.localStorage.diskIndex || '{}') as LocalDiskIndex;
@ -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();

View File

@ -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';

View File

@ -9,7 +9,7 @@ declare global {
new(options?: AudioWorkletNodeOptions): AudioWorkletProcessor;
};
function registerProcessor(name: string, ctor :{ new(): AudioWorkletProcessor; }): void
function registerProcessor(name: string, ctor :{ new(): AudioWorkletProcessor; }): void;
}
export class AppleAudioProcessor extends AudioWorkletProcessor {

View File

@ -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';

View File

@ -202,7 +202,7 @@ type Key2e = DeepMemberOf<typeof keys2e>;
type Key = Key2 | Key2e;
type KeyFunction = (key: KeyboardEvent) => void
type KeyFunction = (key: KeyboardEvent) => void;
export default class KeyBoard {
private kb: HTMLElement;

View File

@ -1,95 +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));
}
}
}
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
@ -100,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);
}
};
@ -119,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;
@ -129,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;
@ -143,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;
}

View File

@ -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';

View File

@ -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
View 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"
}
]

1216
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,7 @@
"build": "rimraf dist/* && webpack --mode=production",
"dev": "webpack serve --mode=development",
"index": "bin/index > json/disks/index.js",
"lint": "eslint '**/*.js' '**/*.ts'",
"lint": "eslint '**/*.js' '**/*.ts' '**/*.tsx'",
"start": "webpack serve --mode=development --progress",
"test": "jest"
},
@ -25,18 +25,20 @@
"homepage": "https://github.com/whscullin/apple2js#readme",
"devDependencies": {
"@babel/core": "^7.9.0",
"@babel/plugin-transform-react-jsx": "^7.17.3",
"@babel/preset-env": "^7.9.0",
"@babel/preset-typescript": "^7.16.7",
"@testing-library/dom": "^7.30.3",
"@testing-library/user-event": "^13.1.3",
"@types/jest": "^27.0.2",
"@types/jest-image-snapshot": "^4.3.1",
"@types/micromodal": "^0.3.2",
"@typescript-eslint/eslint-plugin": "^5.4.0",
"@typescript-eslint/parser": "^5.4.0",
"@typescript-eslint/eslint-plugin": "^5.22.0",
"@typescript-eslint/parser": "^5.22.0",
"ajv": "^6.12.0",
"babel-jest": "^27.2.4",
"canvas": "^2.8.0",
"eslint": "^8.3.0",
"eslint": "^8.15.0",
"file-loader": "^6.0.0",
"jest": "^27.2.4",
"jest-image-snapshot": "^4.5.1",
@ -54,6 +56,8 @@
},
"dependencies": {
"apple2shader": "0.0.3",
"micromodal": "^0.4.2"
"classnames": "^2.3.1",
"micromodal": "^0.4.2",
"preact": "^10.7.1"
}
}

View File

@ -25,7 +25,7 @@ const detail = !!process.env.JEST_DETAIL;
/**
* Memory address and value
*/
type MemoryValue = [address: word, value: byte]
type MemoryValue = [address: word, value: byte];
/**
* Represents initial and final CPU and memory states
@ -50,7 +50,7 @@ type MemoryValue = [address: word, value: byte]
/**
* CPU cycle memory operation
*/
type Cycle = [address: word, value: byte, type: 'read'|'write']
type Cycle = [address: word, value: byte, type: 'read'|'write'];
/**
* One test record

View File

@ -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');
});

View File

@ -1,7 +1,7 @@
import { MemoryPages, byte, word } from 'js/types';
import { assertByte } from './asserts';
export type Log = [address: word, value: byte, types: 'read'|'write']
export type Log = [address: word, value: byte, types: 'read'|'write'];
export class TestMemory implements MemoryPages {
private data: Buffer;
private logging: boolean = false;

View File

@ -1,5 +1,8 @@
{
"compilerOptions": {
"jsx": "react",
"jsxFactory": "h",
"jsxFragmentFactory": "Fragment",
"module": "esnext",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
@ -10,6 +13,7 @@
"noUnusedLocals": true,
"noUnusedParameters": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"sourceMap": true,
"strictNullChecks": true,
"outDir": "dist",
@ -23,7 +27,10 @@
"js/*": [
"js/*"
],
"test/*": [
"json/*": [
"json/*"
],
"test/*": [
"test/*"
]
}

View File

@ -7,7 +7,7 @@ const baseConfig = {
module: {
rules: [
{
test: /\.ts$/i,
test: /\.tsx?$/i,
use: [
{
loader: 'ts-loader'
@ -24,7 +24,11 @@ const baseConfig = {
chunkFilename: '[name].bundle.js',
},
resolve: {
extensions: ['.ts', '.js'],
extensions: ['.ts', '.tsx', '.js'],
alias: {
js: path.resolve(__dirname, 'js/'),
json: path.resolve(__dirname, 'json/'),
}
},
};
@ -32,7 +36,8 @@ const appConfig = merge(baseConfig,
{
entry: {
main2: path.resolve('js/entry2.ts'),
main2e: path.resolve('js/entry2e.ts')
main2e: path.resolve('js/entry2e.ts'),
preact: path.resolve('js/entry.tsx'),
},
output: {
library: {