/* * Copyright (c) 2025 Robin E. R. Davies * All rights reserved. * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ import { UiPlugin, UiControl, ControlType, UiFileProperty } from './Lv2Plugin'; import React from 'react'; import CloseIcon from '@mui/icons-material/Close'; import IconButtonEx from './IconButtonEx'; import Typography from '@mui/material/Typography/Typography'; import ModGuiErrorBoundary from './ModGuiErrorBoundary'; import OkDialog from './OkDialog'; import { pathFileName, pathParentDirectory } from './FileUtils'; import JsonAtom from './JsonAtom'; import { PiPedalModel, PiPedalModelFactory, MonitorPortHandle, State, ListenHandle, FileRequestResult } from './PiPedalModel'; const RANGE_SCALE = 120; // 120 pixels to move from 0 to 1. const FINE_RANGE_SCALE = RANGE_SCALE * 10; // 1200 pixels to move from 0 to 1. const ULTRA_FINE_RANGE_SCALE = RANGE_SCALE * 50; // 12000 pixels to move from 0 to 1. const WHEEL_RANGE_SCALE = 120 * 20; const FINE_WHEEL_RANGE_SCALE = WHEEL_RANGE_SCALE * 10; const ULTRA_FINE_WHEEL_RANGE_SCALE = WHEEL_RANGE_SCALE * 50; export type BypassChangedCallback = (value: boolean) => void; function HtmlEncode(value: string): string { value = value.replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); return value; } function htmlEncodeTest() { let testString = "&<>'\"\\"; let encoded = HtmlEncode(testString); let div = document.createElement("div"); div.innerHTML = `
${encoded}
`; let innerDiv = div.children[0] as HTMLDivElement; if (innerDiv.getAttribute("data-text") !== testString) { throw new Error("HtmlEncode failed to encode properly: " + encoded); } } htmlEncodeTest(); function PathToString(json: any): string { let t = new JsonAtom(json); if (t.isPath()) { return t.asPath(); } if (t.isString()) { return t.asString(); } return ""; } function StringToPath(path: string): any { return JsonAtom._Path(path).asAny(); } export interface ModGuiHostProps { instanceId: number, plugin: UiPlugin; onClose: () => void; width?: number; height?: number; test?: boolean; handleFileSelect: (instanceId: number, propertyUri: string, filePath: string) => void; onContentReady: (ready: boolean) => void; } export interface ModGuiJs { set_port_value: (symbol: string, value: number) => void; patch_get: (uri: string) => any; patch_set: (uri: string, valuetype: string, value: any) => void; get_custom_resource_filename: (filename: string) => string; get_port_index_for_symbol: (symbol: string) => number; get_port_symbol_for_index: (index: number) => string | null; }; export interface ModGuiControl { //setControlValue: (value: number) => void; onMounted: () => void; onUnmount: () => void; render(): HTMLElement | null; }; interface CustomSelectPathControlProps { instanceId: number; plugin: UiPlugin; propertyUri: string, hostSite: PiPedalModel; handleFileSelect: (instanceId: number, propertyUri: string, filePath: string) => void; }; class CustomSelectPathControl implements ModGuiControl { private props: CustomSelectPathControlProps; private frameElement?: HTMLElement; private animationFrameId: number | null = null; constructor(props: CustomSelectPathControlProps) { this.props = props; } private isInlineList: boolean = false; requestUpdate() { if (!this.mounted) return; if (!this.frameElement) { return; } if (this.animationFrameId === null) { this.animationFrameId = window.requestAnimationFrame(() => { this.animationFrameId = null; this.updateImage(); }); } } cancelRequestUpdate() { let animationFrameId = this.animationFrameId; if (animationFrameId !== null) { window.cancelAnimationFrame(animationFrameId); this.animationFrameId = null; } } private getFileProperty(): UiFileProperty | null { for (let property of this.props.plugin.fileProperties) { if (property.patchProperty === this.props.propertyUri) { return property; } } return null; } navBreadcrumbText(breadcrumbs: { pathname: string, displayName: string }[]): string { let breadcrumbText = ""; for (let i = 0; i < breadcrumbs.length - 1; ++i) { let breadcrumb = breadcrumbs[i]; breadcrumbText += " / "; } let breadcrumb = breadcrumbs[breadcrumbs.length - 1]; breadcrumbText += "
" + HtmlEncode(breadcrumb.displayName) + "
"; return breadcrumbText; } makeEnumeratedListItem( type: string, basename: string, fullname: string, encodebaseName?: boolean ): HTMLElement { type = HtmlEncode(type); fullname = HtmlEncode(fullname); if (encodebaseName !== false) { basename = HtmlEncode(basename); } let itemHtml = this.enumeratedListItemTemplate .replace(/{{basename}}/g, basename) .replace(/{{filetype}}/g, type) .replace(/{{fullname}}/g, fullname); let t = document.createElement("div"); t.innerHTML = itemHtml; let itemElement = t.firstElementChild as HTMLElement; return itemElement; } fileRequestResult: FileRequestResult | null = null; async requestDirectoryUpdate() { let model = PiPedalModelFactory.getInstance(); let fileProperty = this.getFileProperty(); if (!fileProperty) { return; } let files = await model.requestFileList2(this.navDirectory || "", fileProperty); this.fileRequestResult = files; if (!this.mounted) { return; } if (this.enumeratedListContainerElement) { this.enumeratedListContainerElement.innerHTML = ""; // remove all children. if (files.breadcrumbs.length >= 2) { let parentDirectory = files.breadcrumbs[files.breadcrumbs.length - 2].pathname; let dotdotHtml = "
" + "
[ ../ ]
" + "
" + this.navBreadcrumbText(files.breadcrumbs) + "
"; let dotdotElement = this.makeEnumeratedListItem( "directory", dotdotHtml, parentDirectory, false ); dotdotElement.style.setProperty("border-bottom", "1px solid #888"); this.enumeratedListContainerElement.appendChild(dotdotElement); } else { // mod-role="enumeration-option" mod-filetype="{{filetype}}" mod-parameter-value="{{fullname}}" let dotdotHtml = "
" + "
 
" + "
" + this.navBreadcrumbText(files.breadcrumbs) + "
"; let dotdotElement = this.makeEnumeratedListItem( "directory", dotdotHtml, "", false ); dotdotElement.style.setProperty("border-bottom", "1px solid #888"); this.enumeratedListContainerElement.appendChild(dotdotElement); } for (let file of files.files) { let displayName = file.displayName; if (file.isDirectory) { displayName = '[ ' + displayName + '/ ]'; } let fileType = file.isDirectory ? "directory" : "file"; let itemElement = this.makeEnumeratedListItem( fileType, displayName, file.pathname ); this.enumeratedListContainerElement.appendChild(itemElement); } this.updateSelection(); } } private navDirectory: string | null = null; setNavDirectory(navDirectory: string | null) { if (this.navDirectory !== navDirectory) { this.navDirectory = navDirectory; this.requestDirectoryUpdate(); } } private pathValue: string | null = null; setPathValue(value: string) { if (this.pathValue !== value) { this.pathValue = value; if (this.pathValue !== "" || this.navDirectory === null) { let browsePath = pathParentDirectory(value); this.setNavDirectory(browsePath); } this.requestUpdate(); } if (this.enumeratedValueElement) { this.enumeratedValueElement.textContent = pathFileName(this.pathValue); } } private updateImage() { if (!this.frameElement || !this.mounted) { return; } // this.frameElement.querySelectorAll('[.mod_enumerated_list]') // .forEach((inputControl: Element) => { // (inputControl as HTMLElement).textContent = text; // }); // this.frameElement.querySelectorAll('[mod-role=enumeration-option]') // .forEach((inputControl: Element) => { // let strValue = inputControl.getAttribute("mod-port-value"); // let value = parseFloat(strValue || ""); // inputControl.classList.remove("selected"); // if (value === this.value) { // inputControl.classList.add("selected"); // } // (inputControl as HTMLElement).textContent = text; // }); //this.frameElement.textContent = text; } handlePropertyValueChange(value: any) { let path: string = PathToString(value); this.setPathValue(path); } private mounted: boolean = false; private listenHandle: ListenHandle | null = null; onMounted() { this.mounted = true; this.props.hostSite.monitorPatchProperty(this.props.instanceId, this.props.propertyUri, (instanceId, propertyUri, value) => { this.handlePropertyValueChange(value); } ); this.props.hostSite.getPatchProperty(this.props.instanceId, this.props.propertyUri) .then((value: any) => { this.handlePropertyValueChange(value); }). catch((error: any) => { if (error instanceof Error) { console.error((error as Error).message); } else { console.error(error.toString()); } this.handlePropertyValueChange(""); }); this.requestUpdate(); } onUnmount() { if (this.listenHandle) { this.props.hostSite.cancelMonitorPatchProperty(this.listenHandle); this.listenHandle = null; } this.mounted = false; this.cancelRequestUpdate(); } render(): HTMLElement | null { return null; } toggleVisibility(element: HTMLElement) { element.classList.toggle("hidden"); let display = element.style.display; if (display === "none" || display === "") { element.style.display = "block"; } else { element.style.display = "none"; } } getValueElement(): HTMLElement | null { return null; } updateSelection() { if (!this.frameElement) { return; } let valueElement = this.getValueElement(); if (valueElement) { if (this.pathValue === null || this.pathValue === "") { valueElement.textContent = "No file selected"; return; } valueElement.textContent = this.pathValue; } if (this.isInlineList && this.enumeratedListContainerElement) { for (let i = 0; i < this.enumeratedListContainerElement.children.length; i++) { let child = this.enumeratedListContainerElement.children[i]; let strValue = child.getAttribute("mod-parameter-value"); let strType = child.getAttribute("mod-filetype"); if (strValue === null) return; if (strValue === this.pathValue && strType !== "directory") { child.classList.add("selected"); } else { child.classList.remove("selected"); } }; } } handleClick(event: MouseEvent) { if (!this.frameElement) { return; } event.preventDefault(); event.stopPropagation(); let valueElement = this.getValueElement(); if (event.target === valueElement) { // A click on the value element, so launch the file browser. hooboy! return; } if (!event.target) { return; } let target = event.target as HTMLElement; if (target.getAttribute("mod-role") === "enumeration-option") { let strValue = target.getAttribute("mod-parameter-value"); if (strValue === null) return; let strType = target.getAttribute("mod-filetype"); if (strType === "directory") { this.setNavDirectory(strValue); return; } this.setPathValue(strValue); this.updateSelection(); this.props.hostSite.setPatchProperty(this.props.instanceId, this.props.propertyUri, StringToPath(strValue)); return; } } private enumeratedListContainerElement: HTMLElement | null = null; private enumeratedListItemTemplate: string = ""; private enumeratedValueElement: HTMLElement | null = null; getValueControlElement(): HTMLElement | null { if (!this.frameElement) { return null; } return this.frameElement.querySelector('[mod-role=input-parameter-value]') as HTMLElement | null; } async handleValueClick(event: MouseEvent) { event.preventDefault(); event.stopPropagation(); this.props.handleFileSelect( this.props.instanceId, this.props.propertyUri, this.pathValue || "" ); } attach(frameElement: HTMLElement) { this.frameElement = frameElement; this.requestUpdate(); this.frameElement.onclick = (event: MouseEvent) => { this.handleClick(event); } // swipe the enumeraation-option generated by the template. // We'll use it as a template to create new file entries. let enumeratedList = this.frameElement.querySelector(".mod-enumerated-list"); if (enumeratedList) { this.enumeratedListContainerElement = enumeratedList as HTMLElement; if (this.enumeratedListContainerElement.children.length === 1) { let firstChild = this.enumeratedListContainerElement.children[0]; if (firstChild) { this.enumeratedListItemTemplate = firstChild.outerHTML; this.enumeratedListContainerElement.removeChild(firstChild); } } } this.enumeratedValueElement = this.getValueControlElement(); this.isInlineList = this.enumeratedValueElement === null; if (this.enumeratedValueElement) { this.enumeratedValueElement.onclick = (event: MouseEvent) => { this.handleValueClick(event); } } } }; interface CustomSelectControlProps { instanceId: number; pluginControl: UiControl; onValueChanged: (instanceId: number, symbol: string, value: number) => void; monitorPort: ( instanceId: number, symbol: string, interval: number, callback: (value: number) => void) => MonitorPortHandle; unmonitorPort: (handle: MonitorPortHandle) => void; }; class CustomSelectControl implements ModGuiControl { private props: CustomSelectControlProps; private frameElement?: HTMLElement; private value: number = -1.038327E-15 private animationFrameId: number | null = null; constructor(props: CustomSelectControlProps) { this.props = props; } requestUpdate() { if (!this.mounted) return; if (!this.frameElement) { return; } if (this.animationFrameId === null) { this.animationFrameId = window.requestAnimationFrame(() => { this.animationFrameId = null; this.updateImage(); }); } } cancelRequestUpdate() { let animationFrameId = this.animationFrameId; if (animationFrameId !== null) { window.cancelAnimationFrame(animationFrameId); this.animationFrameId = null; } } setControlValue(value: number) { if (this.value !== value) { this.value = value; this.requestUpdate(); } } private updateImage() { if (!this.frameElement || !this.mounted) { return; } let text: string = ""; let bestError = 1E308; for (let scalePoint of this.props.pluginControl.scale_points) { let error = Math.abs(scalePoint.value - this.value); if (error < bestError) { bestError = error; text = scalePoint.label; } } this.frameElement.querySelectorAll('[mod-role=input-control-value]') .forEach((inputControl: Element) => { (inputControl as HTMLElement).textContent = text; }); this.frameElement.querySelectorAll('[mod-role=enumeration-option]') .forEach((inputControl: Element) => { let strValue = inputControl.getAttribute("mod-port-value"); let value = parseFloat(strValue || ""); inputControl.classList.remove("selected"); if (value === this.value) { inputControl.classList.add("selected"); } (inputControl as HTMLElement).textContent = text; }); //this.frameElement.textContent = text; } private mounted: boolean = false; private monitorHandle: MonitorPortHandle | null = null; onMounted() { this.mounted = true; this.monitorHandle = this.props.monitorPort( this.props.instanceId, this.props.pluginControl.symbol, 1.0 / 15, (value: number) => { if (value != this.value) { this.setControlValue(value); } } ); this.requestUpdate(); } onUnmount() { if (this.monitorHandle) { this.props.unmonitorPort(this.monitorHandle); this.monitorHandle = null; } this.mounted = false; this.cancelRequestUpdate(); } render(): HTMLElement | null { return null; } toggleVisibility(element: HTMLElement) { element.classList.toggle("hidden"); let display = element.style.display; if (display === "none" || display === "") { element.style.display = "block"; } else { element.style.display = "none"; } } handleClick(event: MouseEvent) { if (!this.frameElement) { return; } event.preventDefault(); event.stopPropagation(); let enumeratedList = this.frameElement.querySelector(".mod-enumerated-list"); if (enumeratedList) { let element = enumeratedList as HTMLElement; this.toggleVisibility(element) } if (event.target) { let target = event.target as HTMLElement; let role = target.getAttribute("mod-role"); if (role === "enumeration-option") // a click in the drop-down list? { let strValue = target.getAttribute("mod-port-value"); if (strValue && strValue !== "") { let value = parseFloat(strValue); if (!isNaN(value)) { this.setControlValue(value); this.props.onValueChanged(this.props.instanceId, this.props.pluginControl.symbol, value); } } } } } attach(frameElement: HTMLElement) { this.frameElement = frameElement; this.requestUpdate(); this.frameElement.onclick = (event: MouseEvent) => { this.handleClick(event); } } }; interface BypassLightControlProps { instanceId: number; model: PiPedalModel; }; function ModMessage(message: string) { return "Failed to generate UI. (" + message + ")"; } class BypassLightControl implements ModGuiControl { private props: BypassLightControlProps; private frameElement?: HTMLElement; private value: number = -1.038327E-15 private animationFrameId: number | null = null; constructor(props: BypassLightControlProps) { this.props = props; } requestUpdate() { if (!this.mounted) return; if (!this.frameElement) { return; } if (this.animationFrameId === null) { this.animationFrameId = window.requestAnimationFrame(() => { this.animationFrameId = null; this.updateImage(); }); } } cancelRequestUpdate() { let animationFrameId = this.animationFrameId; if (animationFrameId !== null) { window.cancelAnimationFrame(animationFrameId); this.animationFrameId = null; } } setControlValue(value: number) { if (this.value !== value) { this.value = value; this.requestUpdate(); } } private updateImage() { if (!this.frameElement || !this.mounted) { return; } this.frameElement.classList.remove('on', 'off'); this.frameElement.classList.add( this.value ? 'on' : 'off' ); } private mounted: boolean = false; private listenHandle: ListenHandle | null = null; onMounted() { this.mounted = true; this.listenHandle = this.props.model.addPedalboardItemEnabledChangeListener( this.props.instanceId, (instanceId: number, isEnabled: boolean) => { let bypass = isEnabled ? 1.0: 0.0; if (bypass !== this.value) { this.setControlValue(bypass); } } ); this.requestUpdate(); } onUnmount() { if (this.listenHandle) { this.props.model.removePedalboardItemEnabledChangeListener(this.listenHandle); this.listenHandle = null; } this.mounted = false; this.cancelRequestUpdate(); } render(): HTMLElement | null { return null; } attach(frameElement: HTMLElement) { this.frameElement = frameElement; this.requestUpdate(); } }; interface FilmstripControlProps { instanceId: number; pluginControl: UiControl; filmStrip: string; verticalStrip: boolean; onValueChanged: (instanceId: number, symbol: string, value: number) => void; monitorPort: ( instanceId: number, symbol: string, interval: number, callback: (value: number) => void) => MonitorPortHandle; unmonitorPort: (handle: MonitorPortHandle) => void; }; class FilmstripControl implements ModGuiControl { private props: FilmstripControlProps; private frameElement?: HTMLElement; private value: number = -1.038327E-15 private pointerDownValue: number = -1.038327E-15; private isPointerDown: boolean = false; private animationFrameId: number | null = null; constructor(props: FilmstripControlProps) { this.props = props; } requestUpdate() { if (!this.mounted) return; if (!this.frameElement) { return; } if (this.animationFrameId === null) { this.animationFrameId = window.requestAnimationFrame(() => { this.animationFrameId = null; this.updateImagePosition(); }); } } cancelRequestUpdate() { let animationFrameId = this.animationFrameId; if (animationFrameId !== null) { window.cancelAnimationFrame(animationFrameId); this.animationFrameId = null; } } setControlValue(value: number) { if (this.value !== value) { this.value = value; this.requestUpdate(); } } static urlRegex = /url\(["']?([^"')]+)["']?\)/i; getImageUrl() { if (!this.frameElement) { throw new Error("Logic error: frameElement is not set."); } let bgImage = getComputedStyle(this.frameElement).backgroundImage; let urlMatch = bgImage.match(FilmstripControl.urlRegex); if (urlMatch && urlMatch.length > 1) { return urlMatch[1]; } return null; } private currentImageUrl: string = ""; private updateImagePosition() { if (!this.frameElement || !this.mounted) { return; } let rotationAttr = this.frameElement.getAttribute('mod-widget-rotation'); if (rotationAttr && rotationAttr !== "") { let rotation = parseFloat(rotationAttr); if (isNaN(rotation)) { console.error("pipedal: Invalid mod-widget-rotation value: " + rotationAttr); return; } let range = this.valueToRange(this.value); this.frameElement.style.transform = `rotate(${rotation * range - rotation / 2}deg)`; return; } let imageUrl = this.getImageUrl(); if (!imageUrl) { return; } if (imageUrl != this.currentImageUrl) { this.filmstripInfo = null; this.currentImageUrl = imageUrl; this.imgElement = new Image(); let img = this.imgElement; img.onload = () => { if (!this.frameElement) { return; } let computedStyle = window.getComputedStyle(this.frameElement); let strWidth = computedStyle.getPropertyValue('width') || "0"; let frameWidth = parseFloat(strWidth); if (isNaN(frameWidth) || frameWidth <= 0) { frameWidth = 0; } let strHeight = computedStyle.getPropertyValue("height") || "0"; let frameHeight = parseFloat(strHeight); if (isNaN(frameHeight) || frameHeight <= 0) { frameHeight = 0; } let iHeight = 0; let bgSize = computedStyle.getPropertyValue('background-size'); if (bgSize) { let bgSizeParts = bgSize.split(' '); if (bgSizeParts.length === 2) { iHeight = parseFloat(bgSizeParts[1]); } } if (iHeight === 0) { iHeight = frameHeight; } let imgFrameWidth = Math.round(frameWidth / iHeight * img.naturalHeight); let nFrames = Math.round(img.naturalWidth / imgFrameWidth); this.filmstripInfo = { width: img.naturalWidth, height: img.naturalHeight, frameWidth: frameWidth, frameHeight: frameHeight, nFrames: nFrames }; this.requestUpdate(); }; img.src = imageUrl; return; } if (!this.filmstripInfo) { return; // Not loaded yet } let xOffset = 0; let yOffset = 0; let pluginControl = this.props.pluginControl; let value = this.isPointerDown ? this.pointerDownValue : this.value; value = this.clampValue(value); let isHorizontalStrip = this.filmstripInfo.frameWidth / this.filmstripInfo.frameHeight < this.filmstripInfo.width / this.filmstripInfo.height; if (isHorizontalStrip) { let range = this.valueToRange(value); if (range < 0) { range = 0; } if (range > 1) { range = 1; } let nFrame = Math.round(range * (this.filmstripInfo.nFrames - 1)); xOffset = -nFrame * this.filmstripInfo.frameWidth; yOffset = 0; } else { xOffset = 0; if (this.value <= (pluginControl.min_value + pluginControl.max_value) / 2) { yOffset = 0; // Off position } else { yOffset = -this.frameElement.clientHeight; // On position } } this.frameElement.style.backgroundPosition = `${xOffset}px ${yOffset}px`; } private mounted: boolean = false; private monitorHandle: MonitorPortHandle | null = null; onMounted() { this.mounted = true; this.monitorHandle = this.props.monitorPort( this.props.instanceId, this.props.pluginControl.symbol, 1.0 / 15, (value: number) => { if (value != this.value) { this.setControlValue(value); } } ); this.requestUpdate(); } onUnmount() { if (this.monitorHandle) { this.props.unmonitorPort(this.monitorHandle); this.monitorHandle = null; } this.mounted = false; this.cancelRequestUpdate(); } private filmstripInfo: { width: number, height: number, frameWidth: number, frameHeight: number, nFrames: number } | null = null; private pointerIds: number[] = []; private firstPointerId: number | null = null; lastX: number = 0; lastY: number = 0; handleToggleClick(event: PointerEvent) { event.preventDefault(); event.stopPropagation(); this.firstPointerId = null; this.isPointerDown = false; if (!this.frameElement) { return; // Not mounted yet } if (event.pointerType === "mouse" && event.button !== 0) { return; // Only left mouse button } let newValue: number; if (this.value === this.props.pluginControl.min_value) { newValue = this.props.pluginControl.max_value; } else { newValue = this.props.pluginControl.min_value; } this.setControlValue(newValue); this.props.onValueChanged(this.props.instanceId, this.props.pluginControl.symbol, newValue); } handlePointerDown(event: PointerEvent) { if (!this.frameElement) { return; // Not mounted yet } if (event.pointerType === "mouse" && event.button !== 0) { return; // Only left mouse button } if (this.props.pluginControl.controlType === ControlType.BypassLight) { return; } this.frameElement.setPointerCapture(event.pointerId); if (this.pointerIds.length === 0) { this.firstPointerId = event.pointerId; this.lastX = event.pageX; this.lastY = event.pageY; } this.isPointerDown = true; this.pointerIds.push(event.pointerId); if (this.props.pluginControl.controlType === ControlType.OnOffSwitch || this.props.pluginControl.controlType === ControlType.ABSwitch) { this.handleToggleClick(event); return; } event.preventDefault(); event.stopPropagation(); this.pointerDownValue = this.value; } valueToRange(value: number): number { let uiControl = this.props.pluginControl; if (uiControl) { let range: number; if (uiControl.is_logarithmic) { let minValue = uiControl.min_value; if (minValue === 0) // LSP plugins do this. { minValue = 0.0001; } range = Math.log(value / minValue) / Math.log(uiControl.max_value / minValue); if (!isFinite(range)) { if (range < 0) { range = 0; } else { range = 1.0; } } else if (isNaN(range)) { range = 0; } } else { range = (value - uiControl.min_value) / (uiControl.max_value - uiControl.min_value); if (!isFinite(range)) { if (range < 0) { range = 0; } else { range = 1.0; } } else if (isNaN(range)) { range = 0; } } if (range > 1) range = 1; if (range < 0) range = 0; return range; } return 0; } rangeToValue(range: number): number { if (range < 0) range = 0; if (range > 1) range = 1; let uiControl = this.props.pluginControl; if (uiControl) { let value: number; if (uiControl.min_value === uiControl.max_value) { value = uiControl.min_value; } else { if (uiControl.is_logarithmic) { let minValue = uiControl.min_value; if (minValue === 0) // LSP controls. { minValue = 0.0001; } value = minValue * Math.pow(uiControl.max_value / minValue, range); if (!isFinite(value)) { value = uiControl.max_value; } else if (isNaN(value)) { value = uiControl.min_value; } } else { value = range * (uiControl.max_value - uiControl.min_value) + uiControl.min_value; } } return value; } return 0; } clampValue(value: number): number { let control = this.props.pluginControl; if (control.enumeration_property) { value = control.clampSelectValue(value); } else if (control.toggled_property) { if (value < (control.min_value + control.max_value) / 2) { value = control.min_value; } else { value = control.max_value; } } else if (control.integer_property) { value = Math.round(value); } else if (control.range_steps != 0) { let step = (control.max_value - control.min_value) / control.range_steps; value = Math.round((value - control.min_value) / step) * step + control.min_value; } if (value < control.min_value) { value = control.min_value; } if (value > control.max_value) { value = control.max_value; } return value; } handlePointerMove(event: PointerEvent) { if (!this.frameElement) { return; // Not mounted yet } if (this.firstPointerId === null || !this.frameElement.hasPointerCapture(event.pointerId)) { return; // Not the first pointer or not captured } event.preventDefault(); event.stopPropagation(); let dy = event.pageY - this.lastY; this.lastY = event.pageY; this.lastX = event.pageX; let rate = RANGE_SCALE; // pixels for full range. if (this.pointerIds.length == 2) { rate = FINE_RANGE_SCALE; } else if (this.pointerIds.length > 2) { rate = ULTRA_FINE_RANGE_SCALE; } else if (event.ctrlKey) { rate = ULTRA_FINE_RANGE_SCALE; } else if (event.shiftKey) { rate = FINE_RANGE_SCALE; } let dRange = -dy / rate; let range = this.valueToRange(this.pointerDownValue); range += dRange; if (range < 0) range = 0; if (range > 1) range = 1; let newValue = this.rangeToValue(range); this.pointerDownValue = newValue; this.setControlValue(newValue); this.props.onValueChanged(this.props.instanceId, this.props.pluginControl.symbol, this.clampValue(newValue)); } handlePointerUp(event: PointerEvent) { let index = this.pointerIds.indexOf(event.pointerId); if (index >= 0) { this.pointerIds.splice(index, 1); } else { return; } this.isPointerDown = false; this.requestUpdate(); if (event.pointerId === this.firstPointerId) { this.firstPointerId = null; } if (!this.frameElement) { return; // Not mounted yet } this.frameElement.releasePointerCapture(event.pointerId); event.preventDefault(); event.stopPropagation(); } handlePointerCancel(event: PointerEvent) { if (!this.frameElement) { return; // Not mounted yet } let index = this.pointerIds.indexOf(event.pointerId); if (index >= 0) { this.pointerIds.splice(index, 1); } if (event.pointerId === this.firstPointerId) { this.firstPointerId = null; } if (this.pointerIds.length === 0) { this.firstPointerId = null; this.lastY = 0; this.isPointerDown = false; this.requestUpdate(); } } handleMouseWheel(event: WheelEvent) { if (!this.frameElement) { return; // Not mounted yet } event.preventDefault(); event.stopPropagation(); let rate = WHEEL_RANGE_SCALE; // pixels for full range. if (event.ctrlKey) { rate = ULTRA_FINE_WHEEL_RANGE_SCALE; } else if (event.shiftKey) { rate = FINE_WHEEL_RANGE_SCALE; } let dRange = event.deltaY / rate; let range = this.valueToRange(this.value); range += dRange; if (range < 0) range = 0; if (range > 1) range = 1; let newValue = this.rangeToValue(range); this.setControlValue(newValue); this.props.onValueChanged(this.props.instanceId, this.props.pluginControl.symbol, newValue); } private imgElement: HTMLImageElement | null = null; render(): HTMLElement | null { return null; } attach(frameElement: HTMLElement) { this.frameElement = frameElement; this.frameElement.onpointerdown = (event: PointerEvent) => { this.handlePointerDown(event); }; this.frameElement.onpointerup = (event: PointerEvent) => { if (!this.frameElement) { return; // Not mounted yet } this.handlePointerUp(event); }; this.frameElement.onpointermove = (event: PointerEvent) => { if (!this.frameElement) { return; // Not mounted yet } if (this.frameElement.hasPointerCapture(event.pointerId)) { this.handlePointerMove(event); return; } } this.frameElement.onpointercancel = (event: PointerEvent) => { this.handlePointerCancel(event); } this.frameElement.onwheel = (event: WheelEvent) => { this.handleMouseWheel(event); }; this.requestUpdate(); } }; function ModGuiHost(props: ModGuiHostProps) { let model: PiPedalModel = PiPedalModelFactory.getInstance(); const { plugin, onClose } = props; const [hostDivRef, setHostDivRef] = React.useState(null); const [errorMessage, setErrorMessage] = React.useState(null); const [contentReady, setContentReady] = React.useState(null); const [modGuiControls] = React.useState([]); const [ready, setReady] = React.useState(model.state.get() === State.Ready); const [maximizedUi] = React.useState(false); function updateContentReady(value: boolean) { if (value !== contentReady) { setContentReady(value); props.onContentReady(value); } } if (!plugin.modGui) { return (
No Mod GUI
); } function addPortClass(element: Element, selector: string, className: string) { let children = element.querySelectorAll(selector); // call addClass to each element that matches the selector children.forEach((child) => { child.classList.add(className); }); } function createCustomSelectControl(control: Element, symbol: string, pluginControl: UiControl) { let customSelectControl = new CustomSelectControl( { instanceId: props.instanceId, pluginControl: pluginControl, onValueChanged: (instanceId: number, symbol: string, value: number) => { try { model.setPedalboardControl(instanceId, symbol, value); } catch (error) { } }, monitorPort: model.monitorPort.bind(model), unmonitorPort: model.unmonitorPort.bind(model) } ); customSelectControl.attach(control as HTMLElement); modGuiControls.push(customSelectControl); let modGuiElement = customSelectControl.render(); if (modGuiElement !== null) { control.appendChild(modGuiElement); } customSelectControl.onMounted(); } function monitorBypassPort( instanceId: number, symbol: string, interval: number, callback: (value: number) => void): MonitorPortHandle { return model.addPedalboardItemEnabledChangeListener( instanceId, (instanceId,value) => { callback(value ? 1 : 0); } ); } function unmonitorBypassPort(handle: MonitorPortHandle) { model.removePedalboardItemEnabledChangeListener(handle as ListenHandle); } function createFootswitchControl(control: Element, symbol: string, controlType: ControlType) { let uiControl = new UiControl(); uiControl.symbol = symbol; uiControl.controlType = controlType; uiControl.min_value = 0; uiControl.max_value = 1; uiControl.is_logarithmic = false; uiControl.integer_property = true; let filmstripControl = new FilmstripControl( { instanceId: props.instanceId, pluginControl: uiControl, filmStrip: "/img/footswitch_strip.png", verticalStrip: true, onValueChanged: (instanceId: number, symbol: string, value: number) => { if (symbol === "_bypass") { model.setPedalboardItemEnabled(instanceId, value !== 0); } else { try { model.setPedalboardControl(instanceId, symbol, value); } catch (error) { } } }, monitorPort: symbol === "_bypass" ? monitorBypassPort : model.monitorPort.bind(model), unmonitorPort: symbol == "_bypass" ? unmonitorBypassPort : model.unmonitorPort.bind(model) } ); filmstripControl.attach(control as HTMLElement); modGuiControls.push(filmstripControl); let modGuiElement = filmstripControl.render(); if (modGuiElement !== null) { control.appendChild(modGuiElement); } filmstripControl.onMounted(); } function createCustomSelectPathControl(control: Element) { let pathUri = control.getAttribute("mod-parameter-uri"); if (!pathUri) { setModError("No mod-parameter-uri attribute found on control element."); return; } let selectPathControl = new CustomSelectPathControl({ instanceId: props.instanceId, propertyUri: pathUri, plugin: props.plugin, hostSite: model, handleFileSelect: props.handleFileSelect }); selectPathControl.attach(control as HTMLElement); modGuiControls.push(selectPathControl); selectPathControl.onMounted(); } function createBypassLightControl(control: Element) { let bypassLightControl = new BypassLightControl( { instanceId: props.instanceId, model: model } ); bypassLightControl.attach(control as HTMLElement); modGuiControls.push(bypassLightControl); let modGuiElement = bypassLightControl.render(); if (modGuiElement !== null) { control.appendChild(modGuiElement); } bypassLightControl.onMounted(); } function createDialControl(control: Element, pluginControl: UiControl) { let modGui = plugin.modGui; if (!modGui) { alert("Logic error"); return; } let knobUrl: string = "/img/BlackKnob.png"; if (modGui.knob !== "") { knobUrl = modGui.knob; } let modGuiControl = new FilmstripControl( { instanceId: props.instanceId, pluginControl: pluginControl, filmStrip: knobUrl, verticalStrip: false, onValueChanged: (instanceId: number, symbol: string, value: number) => { try { model.setPedalboardControl(instanceId, symbol, value); } catch (error) { } }, monitorPort: model.monitorPort.bind(model), unmonitorPort: model.unmonitorPort.bind(model) } ); modGuiControls.push(modGuiControl); modGuiControl.attach(control as HTMLElement); let modGuiElement = modGuiControl.render(); if (modGuiElement !== null) { control.appendChild(modGuiElement); } modGuiControl.onMounted(); } function prepareElement(element: Element) { addPortClass(element, '[mod-role="input-audio-port"]', "mod-audio-input"); addPortClass(element, '[mod-role="output-audio-port"]', "mod-audio-output"); // addPortClass(element,'[mod-role="input-midi-port"]', "mod-midi-input"); // addPortClass(element,'[mod-role="output-midi-port"]', "mod-midi-output"); // addPortClass(element,'[mod-role="input-cv-port"]', "mod-cv-input"); // addPortClass(element,'[mod-role="output-cv-port"]', "mod-cv-output"); element.querySelectorAll('[mod-role=input-control-port]') .forEach((control) => { let symbol = control.getAttribute("mod-port-symbol"); if (symbol) { let pluginControl: UiControl | undefined = plugin.getControl(symbol); if (!pluginControl) { setModError(`No plugin info found for symbol ${symbol}`); return; } let widgetType = control.getAttribute("mod-widget") || ""; // 'film': 'film', // 'switch': 'switchWidget', // 'bypass': 'bypassWidget', // 'select': 'selectWidget', // 'string': 'stringWidget', // 'custom-select': 'customSelect', // 'custom-select-path': 'customSelectPath', switch (widgetType) { case "": case "film": createDialControl(control, pluginControl); break; case "switch": createFootswitchControl(control, symbol, ControlType.OnOffSwitch); break; case "custom-select": createCustomSelectControl(control, symbol, pluginControl); break; case "custom-select-path": case "bypass": case "string": setModError("Unsupported widget type: " + widgetType); break; } } else { setModError("No mod-symbol attribute found on control element."); } }); element.querySelectorAll('[mod-role=bypass]') .forEach((control) => { let symbol = "_bypass"; let controlType = ControlType.OnOffSwitch; createFootswitchControl(control, symbol, controlType); }); element.querySelectorAll('[mod-role=bypass-light]') .forEach((control) => { createBypassLightControl(control); }); element.querySelectorAll('[mod-role=input-parameter]') .forEach((control) => { // stub: mod-widget="custom-select-path". Are there other widgets for this? createCustomSelectPathControl(control); }); } function setModError(message: string) { setErrorMessage(ModMessage(message)); } async function requestContent() { updateContentReady(false); try { let modGui = plugin.modGui; if (!modGui) { setModError("No MOD GUI declared for this plugin."); return; } if (!modGui.iconTemplate) { setModError("Template file missing."); return; } if (!modGui.stylesheet) { setModError("Stylesheet file missing."); return; } let encodedUri = encodeURIComponent(props.plugin.uri); let version = props.plugin.minorVersion * 1000 + props.plugin.microVersion; let resourceUrl = model.modResourcesUrl; let queryParams = "?ns=" + encodedUri + "&v=" + version.toString(); let templateUri = resourceUrl + "_/iconTemplate" + queryParams; let cssUri = resourceUrl + "_/stylesheet" + queryParams; let fetchResult = await fetch(templateUri); if (!fetchResult.ok) { setModError("Failed to load template: " + fetchResult.statusText); return; } let parser = new DOMParser(); let docText = await fetchResult.text(); let doc = parser.parseFromString(docText, "text/html"); let element = doc.body.firstElementChild; if (!element) { setModError("No root element found in template."); return; } let cssResult = await fetch(cssUri); if (!cssResult.ok) { setModError("Failed to load stylesheet: " + cssResult.statusText); return; } if (hostDivRef === null) { return; // cancelled. } let t = await cssResult.text(); let cssText = ""; let cssDoc = parser.parseFromString(cssText, "text/html"); let cssElement = cssDoc.getElementById('mod-gui-style'); if (!cssElement) { setModError("No style element found."); return; } if (hostDivRef === null) { return; // cancelled. } prepareElement(element); // add element to the hostDivRef hostDivRef.appendChild(cssElement); hostDivRef.appendChild(element); let width = element.clientWidth; let height = element.clientHeight; hostDivRef.style.width = width + "px"; hostDivRef.style.height = height + "px"; updateContentReady(true); } catch (error) { setModError("Error loading UI: " + (error instanceof Error ? error.message : String(error))); } } React.useEffect(() => { let tHostDivRef = hostDivRef; if (ready) { if (hostDivRef !== null) { requestContent(); } } let stateHandler = (state: State) => { setReady(state === State.Ready); } model.state.addOnChangedHandler(stateHandler) return () => { model.state.removeOnChangedHandler(stateHandler); if (tHostDivRef !== null) { // unmount the custom content. let children = tHostDivRef.children; for (let i = children.length - 1; i >= 0; i--) { let child = children[i]; if (child instanceof HTMLElement) { child.remove(); } } } updateContentReady(false); let mc = modGuiControls; for (let i = 0; i < mc.length; i++) { let modGuiControl = mc[i]; modGuiControl.onUnmount(); } mc.length = 0; // Clear the controls array }; }, [hostDivRef, plugin, ready]); return ( { props.onClose(); setErrorMessage(null); }}>
{ // Prevent click events from propagating to the parent element. event.stopPropagation(); }} > {maximizedUi && ( onClose()} style={{ position: "absolute", top: 16, right: 16, background: "#FFF5", zIndex: 600 }}> )}
{ // Prevent click events from propagating to the parent element. event.stopPropagation(); } } />
{errorMessage !== null && ( { setErrorMessage(null); onClose(); }} text={errorMessage ?? ""} /> )} ); } interface ModGuiPluginPreference { pluginUri: string; useModGui: boolean; } let modGuiPluginPreferences: ModGuiPluginPreference[] | null = null; function loadModGuiPreferences() { if (modGuiPluginPreferences === null) { let prefs = window.localStorage.getItem("modGuiPluginPreferences"); try { if (prefs) { modGuiPluginPreferences = JSON.parse(prefs); } else { modGuiPluginPreferences = []; } } catch (error) { console.error("pipedal: Error parsing modGuiPluginPreferences from localStorage: " + String(error)); modGuiPluginPreferences = []; } } } export function getDefaultModGuiPreference(pluginUri: string) { loadModGuiPreferences(); if (modGuiPluginPreferences === null) { modGuiPluginPreferences = []; } if (modGuiPluginPreferences) { let preference = modGuiPluginPreferences.find(p => p.pluginUri === pluginUri); if (preference) { return preference.useModGui; } } return false; } export function setDefaultModGuiPreference(pluginUri: string, useModGui: boolean) { loadModGuiPreferences(); if (modGuiPluginPreferences === null) { modGuiPluginPreferences = []; } let preference = modGuiPluginPreferences.find(p => p.pluginUri === pluginUri); if (preference) { preference.useModGui = useModGui; } else { modGuiPluginPreferences.push({ pluginUri: pluginUri, useModGui }); } try { window.localStorage.setItem("modGuiPluginPreferences", JSON.stringify(modGuiPluginPreferences)); } catch(error) { console.error("pipedal: Error saving modGuiPluginPreferences to localStorage: " + String(error)); } } export default ModGuiHost;