Split settings into tabs; exposure and focus on the home screen
Some checks failed
CI / build-and-deploy (push) Has been cancelled
Some checks failed
CI / build-and-deploy (push) Has been cancelled
Home screen gains shutter, aperture and ISO as dropdowns plus a focus stepper (three nudge sizes each way, and AF) where the body drives focus over PTP. Those three drop out of the read-only strip rather than being shown twice. Everything is built from what the camera actually reports, so a body without one of these controls doesn't get it rather than showing something that silently fails. The 450D marks shutter and aperture readonly unless the mode dial is somewhere they apply, so the dropdowns disable themselves and say why. Focus uses the EOS manualfocusdrive steps and autofocusdrive, and warns when live view is off, which Canon bodies generally require for focus commands. The settings drawer is now tabbed: app settings stay on the first tab, and each config section the camera reports gets its own. Empty sections are dropped, and a selected section that disappears falls back to the first tab. Tabs wrap rather than scroll - a scrolled-off tab is an undiscoverable one. Reverts the automatic camera search added in the previous commit, restoring the Select camera button. Auto-connect could park with no way to reach the device chooser: WebUSB permissions are per-origin, so a grant on localhost doesn't carry to the deployed copy, and a camera that's asleep or held by another app never arrives. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QK7PKpRVoy69Fpa32R8abb
This commit is contained in:
@@ -51,7 +51,7 @@ export function configValue(config, name) {
|
||||
* simply skipped, so this stays useful on other bodies too.
|
||||
* @param {Config | undefined} config
|
||||
*/
|
||||
export function statusReadout(config) {
|
||||
export function statusReadout(config, exclude = []) {
|
||||
return [
|
||||
['Mode', 'autoexposuremode'],
|
||||
['Shutter', 'shutterspeed'],
|
||||
@@ -62,10 +62,70 @@ export function statusReadout(config) {
|
||||
['Shots left', 'availableshots'],
|
||||
['Target', 'capturetarget']
|
||||
]
|
||||
.filter(([, name]) => !exclude.includes(name))
|
||||
.map(([label, name]) => ({ label, value: configValue(config, name) }))
|
||||
.filter(({ value }) => value !== undefined && value !== '');
|
||||
}
|
||||
|
||||
/**
|
||||
* The exposure controls worth putting on the home screen, in the order a
|
||||
* photographer expects them. Only settable menus qualify - on a 450D these are
|
||||
* readonly unless the mode dial is somewhere they apply (shutter speed is fixed
|
||||
* in Av, aperture in Tv, both in the green square).
|
||||
*
|
||||
* @param {Config | undefined} config
|
||||
*/
|
||||
export function exposureControls(config) {
|
||||
return [
|
||||
{ name: 'shutterspeed', label: 'Shutter' },
|
||||
{ name: 'aperture', label: 'Aperture' },
|
||||
{ name: 'iso', label: 'ISO' }
|
||||
]
|
||||
.map(entry => ({ ...entry, node: findConfig(config, entry.name) }))
|
||||
.filter(
|
||||
entry =>
|
||||
entry.node &&
|
||||
(entry.node.type === 'menu' || entry.node.type === 'radio')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* What focus control this body exposes over PTP.
|
||||
*
|
||||
* Canon EOS bodies drive the lens through `manualfocusdrive`, a menu whose
|
||||
* choices are step sizes in each direction ("Near 1".."Far 3"); setting one
|
||||
* nudges focus and the value falls back to None. `autofocusdrive` is a
|
||||
* momentary toggle that triggers AF. Neither is guaranteed to exist, and on
|
||||
* EOS both generally need live view running.
|
||||
*
|
||||
* @param {Config | undefined} config
|
||||
*/
|
||||
export function detectFocusControls(config) {
|
||||
let drive = findConfig(config, 'manualfocusdrive');
|
||||
/** @type {{ name: string, near: string[], far: string[] } | null} */
|
||||
let manual = null;
|
||||
if (
|
||||
drive &&
|
||||
(drive.type === 'menu' || drive.type === 'radio') &&
|
||||
!drive.readonly
|
||||
) {
|
||||
// Sorted so index 0 is the smallest nudge in each direction.
|
||||
let byStep = (a, b) => (parseInt(a, 10) || 0) - (parseInt(b, 10) || 0);
|
||||
let near = drive.choices.filter(c => /near/i.test(c)).sort(byStep);
|
||||
let far = drive.choices.filter(c => /far/i.test(c)).sort(byStep);
|
||||
if (near.length && far.length) manual = { name: drive.name, near, far };
|
||||
}
|
||||
|
||||
let auto = findConfig(config, 'autofocusdrive');
|
||||
let mode = findConfig(config, 'focusmode');
|
||||
|
||||
return {
|
||||
manual,
|
||||
autofocus: auto && auto.type === 'toggle' && !auto.readonly ? auto.name : null,
|
||||
mode: mode && 'value' in mode ? mode : null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a gphoto2 shutter speed string ("1/250", "30", "0.3", "bulb") into
|
||||
* seconds. Returns undefined when it can't be parsed.
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
|
||||
*/
|
||||
|
||||
import { h } from 'preact';
|
||||
import { h, Component } from 'preact';
|
||||
import {
|
||||
secondsUntilNext,
|
||||
formatDuration,
|
||||
@@ -25,7 +25,9 @@ import {
|
||||
import {
|
||||
statusReadout,
|
||||
configValue,
|
||||
shutterSpeedSeconds
|
||||
shutterSpeedSeconds,
|
||||
exposureControls,
|
||||
detectFocusControls
|
||||
} from './config-utils.js';
|
||||
|
||||
/** @typedef {import('./settings.js').Prefs} Prefs */
|
||||
@@ -141,6 +143,177 @@ function SequenceStatus({ state, prefs, voiceSupported, onToggleVoice }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A camera setting as a dropdown, with the round-trip to the body surfaced.
|
||||
*
|
||||
* Each change is a USB round-trip that takes the best part of a second, so the
|
||||
* control locks and shows a spinner rather than pretending it was instant and
|
||||
* then snapping back to the old value.
|
||||
*
|
||||
* @extends Component<{
|
||||
* node: import('web-gphoto2').Config,
|
||||
* label: string,
|
||||
* setValue: (name: string, value: any) => Promise<void>
|
||||
* }, { pending: boolean }>
|
||||
*/
|
||||
class ConfigSelect extends Component {
|
||||
state = { pending: false };
|
||||
|
||||
handleChange = async e => {
|
||||
let value = e.currentTarget.value;
|
||||
this.setState({ pending: true });
|
||||
try {
|
||||
await this.props.setValue(this.props.node.name, value);
|
||||
} finally {
|
||||
this.setState({ pending: false });
|
||||
}
|
||||
};
|
||||
|
||||
render(
|
||||
/** @type {ConfigSelect['props']} */ { node, label },
|
||||
/** @type {ConfigSelect['state']} */ { pending }
|
||||
) {
|
||||
// Callers only pass menu/radio nodes; the union type doesn't know that.
|
||||
let { choices = [], value } = /** @type {any} */ (node);
|
||||
let locked = node.readonly || pending;
|
||||
return h(
|
||||
'label',
|
||||
{ class: 'field' },
|
||||
h(
|
||||
'span',
|
||||
null,
|
||||
label,
|
||||
pending ? h('span', { class: 'mini-spinner' }) : undefined
|
||||
),
|
||||
h(
|
||||
'select',
|
||||
{
|
||||
value,
|
||||
disabled: locked,
|
||||
title: node.readonly
|
||||
? `${label} is fixed by the camera in this mode`
|
||||
: undefined,
|
||||
onChange: this.handleChange
|
||||
},
|
||||
choices.map(choice => h('option', { key: choice, value: choice }, choice))
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutter / aperture / ISO, straight on the home screen.
|
||||
*
|
||||
* @param {{
|
||||
* config: import('web-gphoto2').Config | undefined,
|
||||
* setValue: (name: string, value: any) => Promise<void>
|
||||
* }} props
|
||||
*/
|
||||
function ExposureControls({ config, setValue }) {
|
||||
let controls = exposureControls(config);
|
||||
if (!controls.length) return undefined;
|
||||
let allLocked = controls.every(c => c.node.readonly);
|
||||
let mode = configValue(config, 'autoexposuremode');
|
||||
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'card' },
|
||||
h('h3', { class: 'card-title' }, 'Exposure'),
|
||||
h(
|
||||
'div',
|
||||
{ class: 'field-grid tight' },
|
||||
controls.map(({ name, label, node }) =>
|
||||
h(ConfigSelect, { key: name, node, label, setValue })
|
||||
)
|
||||
),
|
||||
allLocked
|
||||
? h(
|
||||
'p',
|
||||
{ class: 'notice' },
|
||||
`The camera is driving exposure itself${
|
||||
mode ? ` in ${mode}` : ''
|
||||
} — switch the mode dial to M to set these from here.`
|
||||
)
|
||||
: undefined
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus, where the body supports driving it over PTP.
|
||||
*
|
||||
* @param {{
|
||||
* config: import('web-gphoto2').Config | undefined,
|
||||
* setValue: (name: string, value: any) => Promise<void>,
|
||||
* livePreview: boolean
|
||||
* }} props
|
||||
*/
|
||||
function FocusControls({ config, setValue, livePreview }) {
|
||||
let { manual, autofocus, mode } = detectFocusControls(config);
|
||||
if (!manual && !autofocus) return undefined;
|
||||
|
||||
// Three nudge sizes per direction, largest on the outside.
|
||||
let step = (choice, glyph, title) =>
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
key: choice,
|
||||
type: 'button',
|
||||
class: 'focus-step',
|
||||
title,
|
||||
onclick: () => setValue(manual.name, choice)
|
||||
},
|
||||
glyph
|
||||
);
|
||||
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'card' },
|
||||
h(
|
||||
'h3',
|
||||
{ class: 'card-title' },
|
||||
'Focus',
|
||||
mode ? h('span', { class: 'card-title-note' }, String(mode.value)) : undefined
|
||||
),
|
||||
h(
|
||||
'div',
|
||||
{ class: 'focus-row' },
|
||||
manual
|
||||
? h(
|
||||
'div',
|
||||
{ class: 'focus-steps' },
|
||||
h('span', { class: 'focus-end' }, 'Near'),
|
||||
[...manual.near].reverse().map((choice, i) =>
|
||||
step(choice, '◀'.repeat(manual.near.length - i), `Focus nearer — ${choice}`)
|
||||
),
|
||||
manual.far.map((choice, i) =>
|
||||
step(choice, '▶'.repeat(i + 1), `Focus further — ${choice}`)
|
||||
),
|
||||
h('span', { class: 'focus-end' }, 'Far')
|
||||
)
|
||||
: undefined,
|
||||
autofocus
|
||||
? h(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class: 'secondary',
|
||||
title: 'Trigger autofocus',
|
||||
onclick: () => setValue(autofocus, true)
|
||||
},
|
||||
'AF'
|
||||
)
|
||||
: undefined
|
||||
),
|
||||
manual && !livePreview
|
||||
? h(
|
||||
'p',
|
||||
{ class: 'notice warn' },
|
||||
'⚠ Canon bodies generally only accept focus commands with live view running — turn it back on in Settings.'
|
||||
)
|
||||
: undefined
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The one-line description of where the sequence is up to. Shared by the status
|
||||
* card and the fullscreen preview overlay.
|
||||
@@ -209,7 +382,8 @@ export function sequenceSummary(state, prefs) {
|
||||
* canCapture: boolean,
|
||||
* log: { id: number, message: string, kind: string, at: number }[],
|
||||
* voiceSupported: boolean,
|
||||
* onToggleVoice: () => void
|
||||
* onToggleVoice: () => void,
|
||||
* setValue: (name: string, value: any) => Promise<void>
|
||||
* }} props
|
||||
*/
|
||||
export function Home({
|
||||
@@ -226,13 +400,18 @@ export function Home({
|
||||
canCapture,
|
||||
log,
|
||||
voiceSupported,
|
||||
onToggleVoice
|
||||
onToggleVoice,
|
||||
setValue
|
||||
}) {
|
||||
let warnings = running ? [] : sequenceWarnings(prefs, config, hasFolder);
|
||||
let plannedSeconds = prefs.unlimited
|
||||
? Infinity
|
||||
: prefs.startDelaySeconds + prefs.shots * prefs.intervalSeconds;
|
||||
let readout = statusReadout(config);
|
||||
// Whatever is now an editable control doesn't need repeating as a readout.
|
||||
let readout = statusReadout(
|
||||
config,
|
||||
exposureControls(config).map(c => c.name)
|
||||
);
|
||||
|
||||
/** @param {(value: number) => Partial<Prefs>} toPatch */
|
||||
let numberHandler = toPatch => e => {
|
||||
@@ -364,6 +543,9 @@ export function Home({
|
||||
)
|
||||
),
|
||||
|
||||
h(ExposureControls, { config, setValue }),
|
||||
h(FocusControls, { config, setValue, livePreview: prefs.livePreview }),
|
||||
|
||||
readout.length
|
||||
? h(
|
||||
'div',
|
||||
|
||||
@@ -285,6 +285,11 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Shutter / aperture / ISO belong on one line, so allow narrower columns. */
|
||||
.field-grid.tight {
|
||||
grid-template-columns: repeat(auto-fit, minmax(88px, 1fr));
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -353,6 +358,67 @@
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--muted);
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-title-note {
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.mini-spinner {
|
||||
display: inline-block;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
margin-left: 6px;
|
||||
border: 1.5px solid var(--line);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Focus stepper */
|
||||
|
||||
.focus-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.focus-steps {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.focus-end {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.focus-step {
|
||||
flex: 1;
|
||||
padding: 7px 4px;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
.plan {
|
||||
margin: 12px 0 0;
|
||||
font-size: 13px;
|
||||
@@ -598,6 +664,39 @@
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Wraps rather than scrolls: a scrolled-off tab is an undiscoverable
|
||||
one, and two short rows cost less than a hidden overflow. */
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px 4px;
|
||||
padding: 4px 8px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
border-radius: 0;
|
||||
background: none;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
padding: 9px 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab:hover:not(.active) {
|
||||
color: var(--text);
|
||||
border-color: var(--line);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--text);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.drawer-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
@@ -710,23 +809,6 @@
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.searching {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border: 2px solid var(--line);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
@@ -734,7 +816,7 @@
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.spinner {
|
||||
.mini-spinner {
|
||||
animation-duration: 2.4s;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,9 +98,9 @@ class App extends Component {
|
||||
{ once: true }
|
||||
);
|
||||
|
||||
// No connect button: just keep reaching for the camera from the moment
|
||||
// the page loads.
|
||||
this.autoConnect();
|
||||
// Try the camera once at startup; if it isn't among the connections the
|
||||
// browser already knows about, fall back to the picker.
|
||||
this.tryToConnectToCamera();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -159,64 +159,23 @@ class App extends Component {
|
||||
this.narrator.enabled = wasEnabled;
|
||||
};
|
||||
|
||||
grantAccess = async () => {
|
||||
try {
|
||||
// @ts-ignore
|
||||
await Camera.showPicker();
|
||||
} catch (err) {
|
||||
// Dismissing the chooser is not an error worth shouting about.
|
||||
return;
|
||||
}
|
||||
await this.connectOnce();
|
||||
selectDevice = async () => {
|
||||
// @ts-ignore
|
||||
await Camera.showPicker();
|
||||
this.setState({ view: 'status', message: '⌛ Connecting…' });
|
||||
await this.tryToConnectToCamera();
|
||||
};
|
||||
|
||||
/**
|
||||
* Keep trying to attach to a camera the browser has already been given
|
||||
* permission for, so landing on the page is all it takes.
|
||||
*
|
||||
* WebUSB will only show its device chooser from a user gesture, so the one
|
||||
* case that genuinely can't be automated is the very first time - after that
|
||||
* the permission is remembered and `getDevices()` sees the camera with no
|
||||
* interaction at all.
|
||||
*/
|
||||
async autoConnect() {
|
||||
// Reconnect the moment a known camera is plugged in or switched on.
|
||||
// @ts-ignore
|
||||
navigator.usb?.addEventListener?.('connect', () => this.connectOnce());
|
||||
|
||||
while (!this.camera) {
|
||||
let permitted = await this.permittedDevices();
|
||||
if (permitted.length) {
|
||||
this.setState({ view: 'searching' });
|
||||
if (await this.connectOnce()) return;
|
||||
} else {
|
||||
this.setState({ view: 'permission' });
|
||||
}
|
||||
await wait(1500);
|
||||
}
|
||||
}
|
||||
|
||||
async permittedDevices() {
|
||||
try {
|
||||
// @ts-ignore
|
||||
return await navigator.usb.getDevices();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {Promise<boolean>} whether we're now connected. */
|
||||
async connectOnce() {
|
||||
if (this.camera) return true;
|
||||
async tryToConnectToCamera() {
|
||||
/** @type {Camera} */
|
||||
let camera;
|
||||
try {
|
||||
camera = new Camera();
|
||||
await camera.connect();
|
||||
} catch (err) {
|
||||
// Expected while the camera is off, asleep, or claimed by another app.
|
||||
console.debug('Camera not ready yet:', err);
|
||||
return false;
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
this.setState({ view: 'picker' });
|
||||
return;
|
||||
}
|
||||
this.camera = camera;
|
||||
let supportedOps = await camera.getSupportedOps();
|
||||
@@ -227,7 +186,6 @@ class App extends Component {
|
||||
configValue(this.state.config, 'model') ||
|
||||
'camera'}.`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
async refreshConfig() {
|
||||
@@ -468,38 +426,21 @@ class App extends Component {
|
||||
|
||||
render(/** @type {App['props']} */ props, /** @type {App['state']} */ state) {
|
||||
switch (state.view) {
|
||||
case 'searching':
|
||||
case 'picker':
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'center' },
|
||||
h('h1', null, '⏱ Intervalometer'),
|
||||
h('p', { class: 'searching' }, h('span', { class: 'spinner' }), 'Looking for your camera…'),
|
||||
h(
|
||||
'p',
|
||||
{ class: 'fine-print' },
|
||||
'Switch the camera on and set the mode dial off Auto. It will connect on its own — no need to reload. On macOS, quit Photos and Image Capture if they grabbed it first.'
|
||||
)
|
||||
);
|
||||
|
||||
case 'permission':
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'center' },
|
||||
h('h1', null, '⏱ Intervalometer'),
|
||||
h(
|
||||
'p',
|
||||
null,
|
||||
'One-off setup: the browser needs your permission to talk to the camera.'
|
||||
),
|
||||
h('p', null, 'Connect your Canon 450D over USB and switch it on.'),
|
||||
h(
|
||||
'button',
|
||||
{ type: 'button', class: 'primary big', onclick: this.grantAccess },
|
||||
'🔍 Choose camera'
|
||||
{ type: 'button', class: 'primary big', onclick: this.selectDevice },
|
||||
'🔍 Select camera'
|
||||
),
|
||||
h(
|
||||
'p',
|
||||
{ class: 'fine-print' },
|
||||
'Only needed once — after this it connects by itself whenever you open the page. Chrome will not open its device chooser without a click, which is the one part of this that cannot be automatic. Built on ',
|
||||
'Requires Chrome with WebUSB. On macOS, quit Photos and Image Capture if they grabbed the camera first; on Linux you may need a udev rule. Built on ',
|
||||
h(
|
||||
'a',
|
||||
{ href: 'https://github.com/GoogleChromeLabs/web-gphoto2' },
|
||||
@@ -593,7 +534,8 @@ class App extends Component {
|
||||
canCapture: !!state.supportedOps?.captureImage,
|
||||
log: state.log,
|
||||
voiceSupported: supportsSpeech,
|
||||
onToggleVoice: this.toggleVoice
|
||||
onToggleVoice: this.toggleVoice,
|
||||
setValue: this.setValue
|
||||
})
|
||||
),
|
||||
h(SettingsDrawer, {
|
||||
|
||||
@@ -70,6 +70,31 @@ export function savePrefs(prefs) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The camera's top-level config sections, each of which becomes a tab.
|
||||
* Empty ones are dropped so we don't offer a tab onto nothing.
|
||||
*
|
||||
* @param {import('web-gphoto2').Config | undefined} config
|
||||
* @returns {(import('web-gphoto2').Config & { type: 'section', children: any })[]}
|
||||
*/
|
||||
function cameraSections(config) {
|
||||
if (!config || config.type !== 'window') return [];
|
||||
return /** @type {any} */ (Object.values(config.children).filter(
|
||||
child =>
|
||||
(child.type === 'section' || child.type === 'window') &&
|
||||
Object.keys(child.children).length > 0
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Section labels are all "Camera Actions", "Camera Settings", … - the prefix
|
||||
* is dead weight in a tab that's already inside the camera's settings.
|
||||
* @param {string} label
|
||||
*/
|
||||
function tabLabel(label) {
|
||||
return label.replace(/^camera\s+/i, '') || label;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ label: string, hint?: string, children?: any }} props
|
||||
*/
|
||||
@@ -107,6 +132,9 @@ function Row({ label, hint, children }) {
|
||||
* }>
|
||||
*/
|
||||
export class SettingsDrawer extends Component {
|
||||
/** Selected tab: 'app', or the gphoto2 name of a config section. */
|
||||
state = { tab: 'app' };
|
||||
|
||||
#onKeyDown = (/** @type {KeyboardEvent} */ e) => {
|
||||
if (e.key === 'Escape' && this.props.open) this.props.onClose();
|
||||
};
|
||||
@@ -135,6 +163,17 @@ export class SettingsDrawer extends Component {
|
||||
testVoice
|
||||
} = props;
|
||||
|
||||
// One tab per top-level section the camera reports, so the config tree
|
||||
// stops being one enormous scroll.
|
||||
let sections = cameraSections(config);
|
||||
// The chosen section can vanish if the camera is swapped or reports
|
||||
// differently after a change; fall back rather than render nothing.
|
||||
let tab =
|
||||
this.state.tab !== 'app' && !sections.some(s => s.name === this.state.tab)
|
||||
? 'app'
|
||||
: this.state.tab;
|
||||
let selected = sections.find(s => s.name === tab);
|
||||
|
||||
return h(
|
||||
Fragment,
|
||||
null,
|
||||
@@ -160,6 +199,32 @@ export class SettingsDrawer extends Component {
|
||||
'✕'
|
||||
)
|
||||
),
|
||||
h(
|
||||
'div',
|
||||
{ class: 'tab-bar' },
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class: `tab ${tab === 'app' ? 'active' : ''}`,
|
||||
onclick: () => this.setState({ tab: 'app' })
|
||||
},
|
||||
'App'
|
||||
),
|
||||
sections.map(section =>
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
key: section.name,
|
||||
type: 'button',
|
||||
class: `tab ${tab === section.name ? 'active' : ''}`,
|
||||
title: section.label,
|
||||
onclick: () => this.setState({ tab: section.name })
|
||||
},
|
||||
tabLabel(section.label)
|
||||
)
|
||||
)
|
||||
),
|
||||
h(
|
||||
'div',
|
||||
{ class: 'drawer-body' },
|
||||
@@ -171,6 +236,27 @@ export class SettingsDrawer extends Component {
|
||||
)
|
||||
: undefined,
|
||||
|
||||
selected
|
||||
? h(
|
||||
'form',
|
||||
{
|
||||
class: 'pure-form pure-form-aligned',
|
||||
onSubmit: e => e.preventDefault()
|
||||
},
|
||||
// The section's own children, not the section node itself -
|
||||
// the tab already names it, so a fieldset around it is noise.
|
||||
Object.values(selected.children).map(child =>
|
||||
h(Widget, { key: child.name, config: child, setValue })
|
||||
)
|
||||
)
|
||||
: undefined,
|
||||
|
||||
tab !== 'app'
|
||||
? undefined
|
||||
: h(
|
||||
Fragment,
|
||||
null,
|
||||
|
||||
h(
|
||||
'section',
|
||||
null,
|
||||
@@ -392,22 +478,14 @@ export class SettingsDrawer extends Component {
|
||||
: h(
|
||||
'p',
|
||||
{ class: 'notice' },
|
||||
'This camera does not expose a bulb or eosremoterelease control, so timed long exposures are unavailable. Use the shutter speed setting below instead (up to 30s on the 450D).'
|
||||
'This camera does not expose a bulb or eosremoterelease control, so timed long exposures are unavailable. Use the shutter speed setting instead (up to 30s on the 450D).'
|
||||
)
|
||||
),
|
||||
|
||||
h(
|
||||
'section',
|
||||
null,
|
||||
h('h3', null, 'Camera'),
|
||||
config
|
||||
? h(
|
||||
'form',
|
||||
{ class: 'pure-form pure-form-aligned', onSubmit: e => e.preventDefault() },
|
||||
h(Widget, { config, setValue })
|
||||
)
|
||||
: h('p', { class: 'notice' }, 'Reading camera configuration…')
|
||||
)
|
||||
!config
|
||||
? h('p', { class: 'notice' }, 'Reading camera configuration…')
|
||||
: undefined
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
8
examples/preact/types.d.ts
vendored
8
examples/preact/types.d.ts
vendored
@@ -6,12 +6,8 @@ type LogEntry = {
|
||||
};
|
||||
|
||||
type AppState = {
|
||||
/**
|
||||
* Which top-level screen is showing. 'searching' auto-retries in the
|
||||
* background; 'permission' is the one-off WebUSB grant, which the browser
|
||||
* will not let us trigger without a click.
|
||||
*/
|
||||
view: 'status' | 'searching' | 'permission' | 'ready';
|
||||
/** Which top-level screen is showing. */
|
||||
view: 'status' | 'picker' | 'ready';
|
||||
/** Shown while `view` is 'status'. */
|
||||
message?: string;
|
||||
config?: import('web-gphoto2').Config;
|
||||
|
||||
Reference in New Issue
Block a user