From 36160b247a1e9571d2856eafe00bc2f79ddedb88 Mon Sep 17 00:00:00 2001 From: Nicholas Sidwell Date: Mon, 17 Nov 2025 17:50:18 -0800 Subject: [PATCH 1/2] fix: standardize casing of denki oto --- web-editor/README.md | 2 +- web-editor/index.html | 2 +- web-editor/package.json | 2 +- web-editor/src/composables/use-page-title.ts | 2 +- web-editor/src/layouts/MainLayout.vue | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/web-editor/README.md b/web-editor/README.md index 0aca07e..5a20658 100644 --- a/web-editor/README.md +++ b/web-editor/README.md @@ -1,4 +1,4 @@ -# Denki Oto Web MIDI Editor +# denki oto Web MIDI Editor This template should help get you started developing with Vue 3 in Vite. diff --git a/web-editor/index.html b/web-editor/index.html index 315ce0b..3c4504f 100644 --- a/web-editor/index.html +++ b/web-editor/index.html @@ -4,7 +4,7 @@ - Denki Oto Web MIDI Configurator + Web MIDI Configurator - denki oto
diff --git a/web-editor/package.json b/web-editor/package.json index 1b7bb46..9796ab4 100644 --- a/web-editor/package.json +++ b/web-editor/package.json @@ -2,7 +2,7 @@ "name": "denki-oto-web-midi-editor", "version": "0.0.1", "description": "Browser-based configurator for denki oto products", - "productName": "Denki Oto Web MIDI Editor", + "productName": "denki oto Web MIDI Editor", "author": "Nicholas Sidwell ", "private": true, "type": "module", diff --git a/web-editor/src/composables/use-page-title.ts b/web-editor/src/composables/use-page-title.ts index c222dbb..083e8bd 100644 --- a/web-editor/src/composables/use-page-title.ts +++ b/web-editor/src/composables/use-page-title.ts @@ -2,5 +2,5 @@ import { useMeta } from 'quasar' export const usePageTitle = (deviceName: string) => useMeta({ - title: `${deviceName} Configurator - Denki Oto`, + title: `${deviceName} Configurator - denki oto`, }) diff --git a/web-editor/src/layouts/MainLayout.vue b/web-editor/src/layouts/MainLayout.vue index 1798345..2fa3c18 100644 --- a/web-editor/src/layouts/MainLayout.vue +++ b/web-editor/src/layouts/MainLayout.vue @@ -47,7 +47,7 @@ watch(omx27.connected, () => { - denki-oto WebMIDI Editor + denki-oto Web MIDI Editor From 8aec3dec1b6f3956b9d1e7ed90f6cb3758478ff7 Mon Sep 17 00:00:00 2001 From: Nicholas Sidwell Date: Tue, 18 Nov 2025 13:32:04 -0800 Subject: [PATCH 2/2] fix: address hot-plug issues in web editor + CSS fixes --- web-editor/.prettierrc.json | 2 +- web-editor/src/access/composables/webMidi.ts | 310 +++++----- .../src/components/8x2/MappingsGroup.vue | 14 +- .../src/components/BorderRevealContainer.vue | 4 +- web-editor/src/components/DescriptionCell.vue | 11 +- web-editor/src/components/DescriptionRow.vue | 4 +- web-editor/src/components/InfoCard.vue | 134 ----- web-editor/src/components/KeySwitch.vue | 16 +- web-editor/src/components/KeySwitchBitty.vue | 2 +- .../components/{8x2 => }/LoadingEllipsis.vue | 2 +- .../src/components/Omx27Configurator.vue | 2 +- .../src/components/Omx27ConfiguratorOld.vue | 136 ----- .../src/components/PotConfiguration.vue | 54 +- .../src/components/PotentiometerRow.vue | 535 ------------------ web-editor/src/css/app.scss | 24 +- web-editor/src/layouts/MainLayout.vue | 86 ++- web-editor/src/main.ts | 4 + web-editor/src/pages/HachiNiPage.vue | 280 +++++---- web-editor/src/pages/Omx27Page.vue | 26 +- web-editor/src/utils/clickBurst.ts | 75 +++ web-editor/src/utils/index.ts | 2 + 21 files changed, 496 insertions(+), 1227 deletions(-) delete mode 100644 web-editor/src/components/InfoCard.vue rename web-editor/src/components/{8x2 => }/LoadingEllipsis.vue (78%) delete mode 100644 web-editor/src/components/Omx27ConfiguratorOld.vue delete mode 100644 web-editor/src/components/PotentiometerRow.vue create mode 100644 web-editor/src/utils/clickBurst.ts diff --git a/web-editor/.prettierrc.json b/web-editor/.prettierrc.json index 29a2402..334a585 100644 --- a/web-editor/.prettierrc.json +++ b/web-editor/.prettierrc.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/prettierrc", - "semi": false, + "semi": true, "singleQuote": true, "printWidth": 100 } diff --git a/web-editor/src/access/composables/webMidi.ts b/web-editor/src/access/composables/webMidi.ts index efeb829..1e4a020 100644 --- a/web-editor/src/access/composables/webMidi.ts +++ b/web-editor/src/access/composables/webMidi.ts @@ -1,115 +1,116 @@ -import { computed, readonly, ref, watch } from 'vue' -import type { MidiAccessState, MIDICallbacks } from 'src/access/types' +import { computed, readonly, ref, watch, onUnmounted } from 'vue'; +import type { MidiAccessState, MIDICallbacks } from 'src/access/types'; -const debug = (...message: unknown[]) => console.debug('[Web MIDI]:', ...message) +const debug = (...message: unknown[]) => console.debug('[Web MIDI]:', ...message); export const useWebMidi = ( deviceManufacturer: string, deviceName: string, callbacks: MIDICallbacks, ) => { - const access = ref('pending') - const input = ref(null) - const inputDeviceState = ref('disconnected') - const output = ref(null) - const outputDeviceState = ref('disconnected') - - const inputAbortController = ref(new AbortController()) - const outputAbortController = ref(new AbortController()) - - const connected = computed(() => { - // console.log('checking connected for', deviceManufacturer, deviceName) - // console.log('input.value', input.value) - // console.log('output.value', output.value) - // console.log('inputDeviceState.value', inputDeviceState.value) - // console.log('outputDeviceState.value', outputDeviceState.value) - - return ( + const access = ref('pending'); + const input = ref(null); + const inputDeviceState = ref('disconnected'); + const output = ref(null); + const outputDeviceState = ref('disconnected'); + + const inputAbortController = ref(new AbortController()); + const outputAbortController = ref(new AbortController()); + + const connected = computed( + () => input.value != null && output.value != null && inputDeviceState.value === 'connected' && - outputDeviceState.value === 'connected' - ) - }) + outputDeviceState.value === 'connected', + ); const validPort = (port: MIDIPort): boolean => (port.manufacturer?.includes(deviceManufacturer) ?? false) && - (port.name?.includes(deviceName) ?? false) - - const stateChangeHandler = (e: Event) => { - console.log('stateChangeHandler') - console.log(e) - const eventPort = (e as MIDIConnectionEvent)?.port - - if (!eventPort) { - console.log('no port') - return + (port.name?.includes(deviceName) ?? false); + + const extractPortProperties = (port: MIDIPort | null) => ({ + connection: port?.connection, + id: port?.id, + manufacturer: port?.manufacturer, + name: port?.name, + state: port?.state, + type: port?.type, + }); + + function findPort(portMap: MIDIInputMap): MIDIInput; + function findPort(portMap: MIDIOutputMap): MIDIOutput; + function findPort

( + portMap: P, + ): MIDIInput | MIDIOutput | null { + for (const [, value] of portMap) { + if (validPort(value)) { + return value as P extends MIDIInputMap + ? MIDIInput + : P extends MIDIOutputMap + ? MIDIOutput + : never; + } } - if (!validPort(eventPort)) { - console.log('not valid port') - return - } + return null; + } - const isInput = eventPort.type === 'input' + let midiAccess: MIDIAccess | null = null; - const store = isInput ? inputDeviceState : outputDeviceState - const port = isInput ? input : output + const stateChangeHandler = (e: MIDIConnectionEvent) => { + const eventPort = e.port; + debug('State change fired', extractPortProperties(eventPort)); - store.value = - eventPort.state === 'connected' && eventPort.connection === 'open' - ? 'connected' - : 'disconnected' + // Always rescan all ports + input.value = findPort(midiAccess!.inputs as MIDIInputMap); + output.value = findPort(midiAccess!.outputs as MIDIOutputMap); - debug( - 'State Change Event', - JSON.stringify( - { - ...extractPortProperties(eventPort), - connectionState: store.value, - }, - null, - 2, - ), - ) + if (input.value) { + inputDeviceState.value = + input.value.state === 'connected' && input.value.connection === 'open' + ? 'connected' + : 'disconnected'; + if (input.value.connection !== 'open') { + input.value.open?.(); + } + } else { + inputDeviceState.value = 'disconnected'; + } - if (eventPort.state === 'disconnected') { - port.value = null + if (output.value) { + outputDeviceState.value = + output.value.state === 'connected' && output.value.connection === 'open' + ? 'connected' + : 'disconnected'; + if (output.value.connection !== 'open') { + output.value.open?.(); + } } else { - port.value = isInput ? (eventPort as MIDIInput) : (eventPort as MIDIOutput) + outputDeviceState.value = 'disconnected'; } - } + }; watch(input, (newValue) => { - inputAbortController.value.abort() - inputAbortController.value = new AbortController() + inputAbortController.value.abort(); + inputAbortController.value = new AbortController(); if (newValue) { callbacks.forEach((callback) => newValue.addEventListener('midimessage', (e) => callback(e, output.value), { signal: inputAbortController.value.signal, }), - ) + ); } - }) + }); watch(output, (newValue) => { - outputAbortController.value.abort() - outputAbortController.value = new AbortController() - - newValue?.open() - }) - - const extractPortProperties = (port: MIDIPort | null) => { - return { - connection: port?.connection, - id: port?.id, - manufacturer: port?.manufacturer, - name: port?.name, - state: port?.state, - type: port?.type, - } - } + outputAbortController.value.abort(); + + outputAbortController.value = new AbortController(); + + newValue?.open(); + }); watch( [access, connected, input, inputDeviceState, output, outputDeviceState], @@ -128,115 +129,90 @@ export const useWebMidi = ( null, 2, ), - ) + ); }, { immediate: true }, - ) + ); - function findPort(portMap: MIDIInputMap): MIDIInput - function findPort(portMap: MIDIOutputMap): MIDIOutput - function findPort

( - portMap: P, - ): MIDIInput | MIDIOutput | null { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - for (const [_, value] of portMap) { - if (validPort(value)) { - return value as P extends MIDIInputMap - ? MIDIInput - : P extends MIDIOutputMap - ? MIDIOutput - : never - } - } + const requestAccess = () => { + navigator + .requestMIDIAccess({ sysex: true }) + .then( + (value) => { + midiAccess = value; - return null - } + debug('MIDI access request: granted'); - // Why the type assertions? TypeScript has taken the view of only supporting the smaller - // subset covered by all browsers, and not the expanded list with things like Web MIDI - // that Chromium et al. support. My understanding, at least. - const midiPermission = { - name: 'midi' as PermissionName, - sysex: true, - } as PermissionDescriptor - - navigator.permissions - .query(midiPermission) - .then((result) => { - debug(`MIDI + Sysex permissions query result: ${result.state}`) - - if (result.state === 'prompt') { - access.value = 'requesting' - } else if (result.state === 'denied') { - access.value = 'disabled' // WebMIDI API permission was denied by user prompt or permission policy - } - }) - .then(() => { - navigator.requestMIDIAccess({ sysex: true }).then( - (midiAccess) => { - debug('MIDI access request: granted') + midiAccess.addEventListener('statechange', stateChangeHandler); - midiAccess.addEventListener('statechange', stateChangeHandler) + debug( + 'Available MIDI Input ports:', + JSON.stringify([...midiAccess.inputs.values()].map(extractPortProperties), null, 2), + ); - const inputPorts: MIDIInput[] = [] + debug( + 'Available MIDI Output ports:', + JSON.stringify([...midiAccess.outputs.values()].map(extractPortProperties), null, 2), + ); - midiAccess.inputs.forEach((i) => inputPorts.push(i as MIDIInput)) + input.value = findPort(midiAccess.inputs as MIDIInputMap); - debug( - 'Available MIDI Input ports:', - JSON.stringify(inputPorts.map(extractPortProperties), null, 2), - ) - // debug( - // 'Available MIDI Input ports:', - // JSON.stringify( - // [...midiAccess.inputs.values()].map(extractPortProperties), - // null, - // 2, - // ), - // ); + output.value = findPort(midiAccess.outputs as MIDIOutputMap); - const outputPorts: MIDIOutput[] = [] + if (input.value) { + input.value.open?.(); + } - midiAccess.outputs.forEach((o) => outputPorts.push(o as MIDIOutput)) + if (output.value) { + output.value.open?.(); + } - debug( - 'Available MIDI Output ports:', - JSON.stringify(outputPorts.map(extractPortProperties), null, 2), - ) - // debug( - // 'Available MIDI Output ports:', - // JSON.stringify( - // [...midiAccess.outputs.values()].map(extractPortProperties), - // null, - // 2, - // ), - // ); - - input.value = findPort(midiAccess.inputs as MIDIInputMap) - output.value = findPort(midiAccess.outputs as MIDIOutputMap) - - access.value = 'enabled' + access.value = 'enabled'; }, () => { - debug('MIDI access request: denied') + debug('MIDI access request: denied'); - access.value = 'disabled' + access.value = 'disabled'; }, ) - }) - .catch((e) => { - // Likely caused by 'midi' not being in the PermissionName enumeration - // or requestMIDIAccess not being a property of navigator: - // i.e. browser doesn't support web MIDI. - debug(`MIDI (permissions query/access request) error: ${e.message}`) + .catch((e) => { + debug(`MIDI access error: ${e.message}`); + + access.value = 'disabled'; + }); + }; + + // Permissions query fallback + if ('permissions' in navigator && 'query' in navigator.permissions) { + navigator.permissions + .query({ name: 'midi' as PermissionName }) + .then((result) => { + debug(`MIDI permissions query result: ${result.state}`); + + if (result.state === 'prompt') { + access.value = 'requesting'; + } else if (result.state === 'denied') { + access.value = 'disabled'; + } + }) + .finally(requestAccess) + .catch(requestAccess); + } else { + requestAccess(); + } - access.value = 'disabled' - }) + onUnmounted(() => { + inputAbortController.value.abort(); + + outputAbortController.value.abort(); + + midiAccess?.removeEventListener('statechange', stateChangeHandler); + }); return { access: readonly(access), connected, input: readonly(input), output: readonly(output), - } -} + }; +}; diff --git a/web-editor/src/components/8x2/MappingsGroup.vue b/web-editor/src/components/8x2/MappingsGroup.vue index 93c4b7f..8b6b6e5 100644 --- a/web-editor/src/components/8x2/MappingsGroup.vue +++ b/web-editor/src/components/8x2/MappingsGroup.vue @@ -126,7 +126,7 @@ const link = computed({ }); - diff --git a/web-editor/src/components/PotConfiguration.vue b/web-editor/src/components/PotConfiguration.vue index 59fd134..41bf991 100644 --- a/web-editor/src/components/PotConfiguration.vue +++ b/web-editor/src/components/PotConfiguration.vue @@ -186,23 +186,6 @@ onMounted(() => {

- -
@@ -223,7 +206,7 @@ onMounted(() => { enter-active-class="animated faster slideInUp" leave-active-class="animated faster slideOutDown"> + label="discard" dense class="bg-black discard-button" style="border: 1px solid white" />
@@ -355,7 +338,7 @@ onMounted(() => { } .bank-buttons-container { - ::v-deep(.q-btn) { + :deep(.q-btn) { border-radius: unset; font-size: 0.75em; font-weight: bold; @@ -382,20 +365,20 @@ onMounted(() => { border-top: 1px solid white; &-container { - ::v-deep(:nth-child(1 of .description-cell)) { + :deep(:nth-child(1 of .description-cell)) { dt { border-left: unset; } } } - ::v-deep(.q-btn[aria-pressed="true"]) { + :deep(.q-btn[aria-pressed="true"]) { background-color: white; } border-radius: 0; - &::v-deep(> .q-btn-item) { + &:deep(> .q-btn-item) { padding: 0 1ch; &:not(:last-child) { @@ -415,15 +398,15 @@ onMounted(() => { width: 8.25ch; z-index: 1; - ::v-deep(.q-field__control::after) { + :deep(.q-field__control::after) { display: none; } - ::v-deep(.q-field__control::before) { + :deep(.q-field__control::before) { display: none; } - ::v-deep(input.q-field__native) { + :deep(input.q-field__native) { color: white; order: 1; padding: 0; @@ -445,44 +428,44 @@ onMounted(() => { } &.modified { - ::v-deep(.q-field__label) { + :deep(.q-field__label) { opacity: 0; transition: opacity 0.25s ease; } } &.one-digit { - ::v-deep(input.q-field__native) { + :deep(input.q-field__native) { width: 4.15ch; } } &.two-digits { - ::v-deep(input.q-field__native) { + :deep(input.q-field__native) { width: 4.65ch; } } &.three-digits { - ::v-deep(input.q-field__native) { + :deep(input.q-field__native) { width: 5.15ch; } } - ::v-deep(.q-field__control) { + :deep(.q-field__control) { align-items: center; height: min-content; } - ::v-deep(.q-field__control-container) { + :deep(.q-field__control-container) { align-items: center; height: 1.5em; justify-content: space-between; } - ::v-deep(.q-field__label) { + :deep(.q-field__label) { align-content: center; color: black; font-size: 0.75em; @@ -509,7 +492,7 @@ onMounted(() => { } .potentiometers-row { - ::v-deep(dt) { + :deep(dt) { background: unset; border-left: 1px solid white; border-right: unset; @@ -544,5 +527,10 @@ onMounted(() => { .discard-button-container { filter: drop-shadow(0px 0px 2.5px black); overflow: hidden; + + // TODO: Remove once hachi-ni editor integration has CSS fully sorted out + .discard-button:hover { + color: white; + } } diff --git a/web-editor/src/components/PotentiometerRow.vue b/web-editor/src/components/PotentiometerRow.vue deleted file mode 100644 index a42e896..0000000 --- a/web-editor/src/components/PotentiometerRow.vue +++ /dev/null @@ -1,535 +0,0 @@ - - - - - diff --git a/web-editor/src/css/app.scss b/web-editor/src/css/app.scss index 2fd9bcc..e7d17cf 100644 --- a/web-editor/src/css/app.scss +++ b/web-editor/src/css/app.scss @@ -1,14 +1,11 @@ // app global css in SCSS form -// @import 'quasar/src/css/index.sass'; -// @import '~quasar/styles/variables.sass'; :root { - --background-color: #222222; --border-color: rgba(255, 255, 255, 0.3); --highlight-color: rgba(127, 127, 127); --q-white: white; --text-color: #fefefe; - // font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; line-height: 1.5; font-weight: 400; @@ -49,17 +46,6 @@ @extend .bg-translucent-grey; } -body.desktop { - font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - -webkit-text-size-adjust: 100%; -} - .border-square-primary { border: 1px solid var(--q-primary); border-radius: 0; @@ -73,20 +59,12 @@ $colors: (primary, secondary, accent, negative, positive, 'white'); } } -// .q-btn { -// @extend .text-glow; -// } - .q-input { .q-field__native { line-height: 0.45rem; } } -// .q-tab { -// @extend .text-glow; -// } - .q-tooltip { background: black; border-radius: 0; diff --git a/web-editor/src/layouts/MainLayout.vue b/web-editor/src/layouts/MainLayout.vue index 2fa3c18..8fa6cee 100644 --- a/web-editor/src/layouts/MainLayout.vue +++ b/web-editor/src/layouts/MainLayout.vue @@ -1,6 +1,6 @@ diff --git a/web-editor/src/main.ts b/web-editor/src/main.ts index cbccb8b..466d16d 100644 --- a/web-editor/src/main.ts +++ b/web-editor/src/main.ts @@ -17,8 +17,12 @@ import App from './App.vue' import router from './router' +import { useClickBurst } from 'src/utils' + const app = createApp(App) +app.directive('click-burst', useClickBurst()) + // app.use(createPinia()) app diff --git a/web-editor/src/pages/HachiNiPage.vue b/web-editor/src/pages/HachiNiPage.vue index 420c4b8..855e7e0 100644 --- a/web-editor/src/pages/HachiNiPage.vue +++ b/web-editor/src/pages/HachiNiPage.vue @@ -1,9 +1,9 @@