@rogieking/figui3 6.9.4 → 6.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/fig-lab.js CHANGED
@@ -1774,6 +1774,827 @@ class FigCanvasControl extends HTMLElement {
1774
1774
  }
1775
1775
  customElements.define("fig-canvas-control", FigCanvasControl);
1776
1776
 
1777
+ /* Oscillator Input */
1778
+ /**
1779
+ * A waveform oscillator input with live SVG preview and parameter controls.
1780
+ * @attr {string} value - JSON string: {"waves":[{"type":"sine","frequency":1,"amplitude":1,"phase":0,"offset":0}]}
1781
+ * @attr {number} precision - Decimal places for output values.
1782
+ * @attr {string} aspect-ratio - SVG editor aspect ratio.
1783
+ * @attr {boolean} edit - Whether to show the editor and number fields. Defaults to true.
1784
+ */
1785
+ class FigInputOscillator extends HTMLElement {
1786
+ #waves = [FigInputOscillator.#defaultWave()];
1787
+ #activeWaveIndex = 0;
1788
+ #precision = 2;
1789
+ #drawWidth = 240;
1790
+ #drawHeight = 120;
1791
+ #isDragging = null;
1792
+ #svg = null;
1793
+ #path = null;
1794
+ #playhead = null;
1795
+ #playheadFrame = 0;
1796
+ #baseline = null;
1797
+ #bounds = null;
1798
+ #handleAmplitude = null;
1799
+ #handleFrequency = null;
1800
+ #addTypeControl = null;
1801
+ #fields = [];
1802
+ #typeControls = [];
1803
+ #waveRows = [];
1804
+ #resizeObserver = null;
1805
+
1806
+ static TYPES = [
1807
+ { name: "Wave", value: "sine" },
1808
+ { name: "Square", value: "square" },
1809
+ { name: "Sawtooth", value: "sawtooth" },
1810
+ { name: "Triangle", value: "triangle" },
1811
+ ];
1812
+
1813
+ static #defaultWave(type = "sine") {
1814
+ return {
1815
+ type,
1816
+ frequency: 1,
1817
+ amplitude: 1,
1818
+ phase: 0,
1819
+ offset: 0,
1820
+ };
1821
+ }
1822
+
1823
+ static get observedAttributes() {
1824
+ return ["value", "precision", "aspect-ratio", "edit", "disabled"];
1825
+ }
1826
+
1827
+ connectedCallback() {
1828
+ this.#precision = this.#readInteger("precision", 2);
1829
+ this.#parseValue(this.getAttribute("value"));
1830
+ this.#syncAspectRatio();
1831
+ this.#render();
1832
+ this.#setupResizeObserver();
1833
+ }
1834
+
1835
+ disconnectedCallback() {
1836
+ this.#isDragging = null;
1837
+ this.#stopPlayhead();
1838
+ if (this.#resizeObserver) {
1839
+ this.#resizeObserver.disconnect();
1840
+ this.#resizeObserver = null;
1841
+ }
1842
+ }
1843
+
1844
+ attributeChangedCallback(name, oldValue, newValue) {
1845
+ if (oldValue === newValue) return;
1846
+
1847
+ if (name === "value") {
1848
+ this.#parseValue(newValue);
1849
+ if (this.isConnected) this.#render();
1850
+ return;
1851
+ }
1852
+
1853
+ if (name === "precision") {
1854
+ this.#precision = this.#readInteger("precision", 2);
1855
+ if (this.isConnected) this.#syncUI();
1856
+ return;
1857
+ }
1858
+
1859
+ if (name === "aspect-ratio") {
1860
+ this.#syncAspectRatio();
1861
+ if (this.#svg) this.#updateWaveform();
1862
+ return;
1863
+ }
1864
+
1865
+ if (name === "edit" || name === "disabled") {
1866
+ if (this.isConnected) this.#render();
1867
+ }
1868
+ }
1869
+
1870
+ get value() {
1871
+ return JSON.stringify(this.data);
1872
+ }
1873
+
1874
+ set value(value) {
1875
+ this.setAttribute(
1876
+ "value",
1877
+ typeof value === "object" && value !== null ? JSON.stringify(value) : value,
1878
+ );
1879
+ }
1880
+
1881
+ get data() {
1882
+ return {
1883
+ waves: this.#waves.map((wave) => this.#roundWave(wave)),
1884
+ };
1885
+ }
1886
+
1887
+ get preset() {
1888
+ const wave = this.#activeWave;
1889
+ return FigInputOscillator.TYPES.find((type) => type.value === wave.type)?.name;
1890
+ }
1891
+
1892
+ #readInteger(name, fallback) {
1893
+ const value = Number.parseInt(this.getAttribute(name) || "", 10);
1894
+ return Number.isFinite(value) ? value : fallback;
1895
+ }
1896
+
1897
+ #readBooleanAttribute(name, defaultValue = false) {
1898
+ const value = this.getAttribute(name);
1899
+ if (value === null) return defaultValue;
1900
+ const normalized = value.trim().toLowerCase();
1901
+ if (normalized === "" || normalized === "true") return true;
1902
+ if (normalized === "false") return false;
1903
+ return true;
1904
+ }
1905
+
1906
+ #isEditEnabled() {
1907
+ return this.getAttribute("edit") !== "false";
1908
+ }
1909
+
1910
+ #isDisabled() {
1911
+ return this.#readBooleanAttribute("disabled", false);
1912
+ }
1913
+
1914
+ #syncAspectRatio() {
1915
+ const aspectRatio = this.getAttribute("aspect-ratio") || "2 / 1";
1916
+ this.style.setProperty("--aspect-ratio", aspectRatio);
1917
+ }
1918
+
1919
+ #parseValue(value) {
1920
+ if (!value) return false;
1921
+
1922
+ let parsed = null;
1923
+ if (typeof value === "string") {
1924
+ try {
1925
+ parsed = JSON.parse(value);
1926
+ } catch {
1927
+ parsed = null;
1928
+ }
1929
+ } else if (typeof value === "object") {
1930
+ parsed = value;
1931
+ }
1932
+
1933
+ if (!parsed) return false;
1934
+
1935
+ const nextWaves = Array.isArray(parsed)
1936
+ ? parsed
1937
+ : Array.isArray(parsed.waves)
1938
+ ? parsed.waves
1939
+ : [parsed];
1940
+
1941
+ this.#waves = nextWaves.map((wave) => this.#normalizeWave(wave));
1942
+ if (!this.#waves.length) {
1943
+ this.#waves = [FigInputOscillator.#defaultWave()];
1944
+ }
1945
+ this.#activeWaveIndex = Math.min(this.#activeWaveIndex, this.#waves.length - 1);
1946
+ return true;
1947
+ }
1948
+
1949
+ #normalizeWave(state = {}) {
1950
+ return {
1951
+ type: this.#normalizeType(state.type),
1952
+ frequency: this.#clampNumber(state.frequency, 1, 0.1, 16),
1953
+ amplitude: this.#clampNumber(state.amplitude, 1, -4, 4),
1954
+ phase: this.#clampNumber(state.phase, 0, -360, 360),
1955
+ offset: this.#clampNumber(state.offset, 0, -4, 4),
1956
+ };
1957
+ }
1958
+
1959
+ #roundWave(wave) {
1960
+ return {
1961
+ type: wave.type,
1962
+ frequency: this.#round(wave.frequency),
1963
+ amplitude: this.#round(wave.amplitude),
1964
+ phase: this.#round(wave.phase),
1965
+ offset: this.#round(wave.offset),
1966
+ };
1967
+ }
1968
+
1969
+ get #activeWave() {
1970
+ if (!this.#waves[this.#activeWaveIndex]) {
1971
+ this.#activeWaveIndex = 0;
1972
+ }
1973
+ return this.#waves[this.#activeWaveIndex] || FigInputOscillator.#defaultWave();
1974
+ }
1975
+
1976
+ #normalizeType(type) {
1977
+ const normalized = String(type || "").toLowerCase();
1978
+ return FigInputOscillator.TYPES.some((item) => item.value === normalized)
1979
+ ? normalized
1980
+ : "sine";
1981
+ }
1982
+
1983
+ #clampNumber(value, fallback, min, max) {
1984
+ const number = Number.parseFloat(value);
1985
+ if (!Number.isFinite(number)) return fallback;
1986
+ return Math.min(max, Math.max(min, number));
1987
+ }
1988
+
1989
+ #round(value) {
1990
+ const scale = 10 ** this.#precision;
1991
+ return Math.round(value * scale) / scale;
1992
+ }
1993
+
1994
+ static #escapeAttribute(value) {
1995
+ return String(value)
1996
+ .replace(/&/g, "&")
1997
+ .replace(/"/g, """)
1998
+ .replace(/</g, "&lt;")
1999
+ .replace(/>/g, "&gt;");
2000
+ }
2001
+
2002
+ static #labelForType(type) {
2003
+ return FigInputOscillator.TYPES.find((item) => item.value === type)?.name || "Wave";
2004
+ }
2005
+
2006
+ static waveIcon(type, size = 24) {
2007
+ const samples = 32;
2008
+ const pad = 5;
2009
+ const draw = size - pad * 2;
2010
+ let d = "";
2011
+ for (let i = 0; i <= samples; i++) {
2012
+ const t = i / samples;
2013
+ const value = FigInputOscillator.#waveValue(type, t);
2014
+ const x = pad + t * draw;
2015
+ const y = pad + (1 - (value + 1) / 2) * draw;
2016
+ d += `${i === 0 ? "M" : "L"}${x.toFixed(1)},${y.toFixed(1)}`;
2017
+ }
2018
+ return `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" fill="none"><path d="${d}" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
2019
+ }
2020
+
2021
+ static #waveValue(type, t, phase = 0) {
2022
+ const cycle = t + phase / 360;
2023
+ const wrapped = cycle - Math.floor(cycle);
2024
+ const angle = cycle * Math.PI * 2;
2025
+
2026
+ switch (type) {
2027
+ case "square":
2028
+ return Math.sin(angle) >= 0 ? 1 : -1;
2029
+ case "sawtooth":
2030
+ return wrapped * 2 - 1;
2031
+ case "triangle":
2032
+ return 1 - Math.abs(wrapped * 4 - 2);
2033
+ default:
2034
+ return Math.sin(angle);
2035
+ }
2036
+ }
2037
+
2038
+ #render() {
2039
+ this.#stopPlayhead();
2040
+ this.innerHTML = this.#getInnerHTML();
2041
+ this.#cacheRefs();
2042
+ this.#syncViewportSize();
2043
+ this.#updateWaveform();
2044
+ this.#setupEvents();
2045
+ this.#startPlayhead();
2046
+ }
2047
+
2048
+ #getInnerHTML() {
2049
+ const disabled = this.#isDisabled() ? " disabled" : "";
2050
+
2051
+ return `<div class="fig-input-oscillator-svg-container">
2052
+ <svg viewBox="0 0 ${this.#drawWidth} ${this.#drawHeight}" class="fig-input-oscillator-svg">
2053
+ <rect class="fig-input-oscillator-bounds" x="0" y="0" width="${this.#drawWidth}" height="${this.#drawHeight}"></rect>
2054
+ <line class="fig-input-oscillator-baseline"></line>
2055
+ <path class="fig-input-oscillator-path"></path>
2056
+ <circle class="fig-input-oscillator-playhead"></circle>
2057
+ <foreignObject class="fig-input-oscillator-handle fig-input-oscillator-amplitude-handle" data-handle="amplitude" width="20" height="20"><div class="fig-input-oscillator-handle-inner"><fig-tooltip text="Amplitude"><fig-handle type="canvas" size="small" aria-label="Oscillator amplitude handle"${disabled}></fig-handle></fig-tooltip></div></foreignObject>
2058
+ <foreignObject class="fig-input-oscillator-handle fig-input-oscillator-frequency-handle" data-handle="frequency" width="20" height="20"><div class="fig-input-oscillator-handle-inner"><fig-tooltip text="Frequency"><fig-handle type="canvas" size="small" aria-label="Oscillator frequency handle"${disabled}></fig-handle></fig-tooltip></div></foreignObject>
2059
+ </svg>
2060
+ </div>
2061
+ ${this.#isEditEnabled() ? this.#getWaveControlsHTML(disabled) : ""}`;
2062
+ }
2063
+
2064
+ #getWaveControlsHTML(disabled) {
2065
+ return `<div class="fig-input-oscillator-waves">
2066
+ ${this.#waves.map((wave, index) => this.#getWaveRowHTML(wave, index, disabled)).join("")}
2067
+ <hstack class="fig-input-oscillator-add-wave">
2068
+ <fig-button class="fig-input-oscillator-add-type-button" type="select" variant="ghost" icon aria-label="Choose waveform type"${disabled}>
2069
+ <fig-icon name="settings"></fig-icon>
2070
+ ${this.#getWaveTypeDropdownHTML("fig-input-oscillator-add-type", "sine", disabled)}
2071
+ </fig-button>
2072
+ </hstack>
2073
+ </div>`;
2074
+ }
2075
+
2076
+ #getWaveRowHTML(wave, index, disabled) {
2077
+ const removeDisabled = disabled || this.#waves.length <= 1 ? " disabled" : "";
2078
+ const active = index === this.#activeWaveIndex ? " data-active" : "";
2079
+ const label = FigInputOscillator.#labelForType(wave.type);
2080
+ return `<details class="fig-input-oscillator-wave" data-wave-index="${index}" open>
2081
+ <summary class="fig-input-oscillator-wave-summary">
2082
+ <label>${FigInputOscillator.waveIcon(wave.type, 24)}<span>${label}</span></label>
2083
+ <fig-button class="fig-input-oscillator-remove-button" variant="ghost" icon data-wave-index="${index}" aria-label="Remove waveform"${removeDisabled}><fig-icon name="minus"></fig-icon></fig-button>
2084
+ </summary>
2085
+ <div class="fig-input-oscillator-fields" data-wave-index="${index}"${active}>
2086
+ ${this.#getNumberFieldHTML(index, "frequency", "Frequency", 0.1, 16, 0.1, "")}
2087
+ ${this.#getNumberFieldHTML(index, "amplitude", "Amplitude", -4, 4, 0.1, "")}
2088
+ ${this.#getNumberFieldHTML(index, "phase", "Phase", -360, 360, 1, "°")}
2089
+ ${this.#getNumberFieldHTML(index, "offset", "Offset", -4, 4, 0.1, "")}
2090
+ </div>
2091
+ </details>`;
2092
+ }
2093
+
2094
+ #getWaveTypeDropdownHTML(className, value, disabled, index = null) {
2095
+ const options = FigInputOscillator.TYPES.map((type) => {
2096
+ const selected = type.value === value ? " selected" : "";
2097
+ return `<option value="${type.value}"${selected}>
2098
+ ${FigInputOscillator.waveIcon(type.value, 24)}
2099
+ <label>${type.name}</label>
2100
+ </option>`;
2101
+ }).join("");
2102
+ const indexAttr = index === null ? "" : ` data-wave-index="${index}"`;
2103
+ const dropdownAttr = index === null ? ' type="dropdown" label="Choose waveform type"' : "";
2104
+ return `<fig-dropdown class="${className}" value="${value}" experimental="modern"${dropdownAttr}${indexAttr}${disabled}>${options}</fig-dropdown>`;
2105
+ }
2106
+
2107
+ #getNumberFieldHTML(index, name, label, min, max, step, units) {
2108
+ const disabled = this.#isDisabled() ? " disabled" : "";
2109
+ const unitsAttr = units
2110
+ ? ` units="${FigInputOscillator.#escapeAttribute(units)}"`
2111
+ : "";
2112
+ const wave = this.#waves[index] || FigInputOscillator.#defaultWave();
2113
+ return `<fig-field class="fig-input-oscillator-field" direction="horizontal" data-wave-index="${index}">
2114
+ <label>${label}</label>
2115
+ <fig-slider name="${name}" data-wave-index="${index}" value="${this.#round(wave[name])}" min="${min}" max="${max}" step="${step}" precision="${this.#precision}" text="true"${unitsAttr}${disabled}></fig-slider>
2116
+ </fig-field>
2117
+ `;
2118
+ }
2119
+
2120
+ #cacheRefs() {
2121
+ this.#svg = this.querySelector(".fig-input-oscillator-svg");
2122
+ this.#path = this.querySelector(".fig-input-oscillator-path");
2123
+ this.#playhead = this.querySelector(".fig-input-oscillator-playhead");
2124
+ this.#baseline = this.querySelector(".fig-input-oscillator-baseline");
2125
+ this.#bounds = this.querySelector(".fig-input-oscillator-bounds");
2126
+ this.#handleAmplitude = this.querySelector('[data-handle="amplitude"]');
2127
+ this.#handleFrequency = this.querySelector('[data-handle="frequency"]');
2128
+ this.#addTypeControl = this.querySelector(".fig-input-oscillator-add-type");
2129
+ this.#typeControls = Array.from(
2130
+ this.querySelectorAll(".fig-input-oscillator-wave-type"),
2131
+ );
2132
+ this.#fields = Array.from(this.querySelectorAll("fig-slider[name]"));
2133
+ this.#waveRows = Array.from(this.querySelectorAll("[data-wave-index]")).filter(
2134
+ (row) => row.classList.contains("fig-input-oscillator-field"),
2135
+ );
2136
+ }
2137
+
2138
+ #setupResizeObserver() {
2139
+ if (this.#resizeObserver || !window.ResizeObserver) return;
2140
+ this.#resizeObserver = new ResizeObserver(() => {
2141
+ if (this.#syncViewportSize()) this.#updateWaveform();
2142
+ });
2143
+ this.#resizeObserver.observe(this);
2144
+ }
2145
+
2146
+ #syncViewportSize() {
2147
+ if (!this.#svg) return false;
2148
+ const rect = this.#svg.getBoundingClientRect();
2149
+ const width = Math.max(1, Math.round(rect.width || 240));
2150
+ const height = Math.max(1, Math.round(rect.height || 120));
2151
+ const changed = width !== this.#drawWidth || height !== this.#drawHeight;
2152
+ this.#drawWidth = width;
2153
+ this.#drawHeight = height;
2154
+ this.#svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
2155
+ return changed;
2156
+ }
2157
+
2158
+ #getValueRange() {
2159
+ let span = 1;
2160
+ const samples = 256;
2161
+ for (let i = 0; i <= samples; i++) {
2162
+ span = Math.max(span, Math.abs(this.#sampleAt(i / samples)));
2163
+ }
2164
+ return {
2165
+ min: -span,
2166
+ max: span,
2167
+ };
2168
+ }
2169
+
2170
+ #toY(value) {
2171
+ const { min, max } = this.#getValueRange();
2172
+ return this.#drawHeight - ((value - min) / (max - min)) * this.#drawHeight;
2173
+ }
2174
+
2175
+ #fromY(y) {
2176
+ const { min, max } = this.#getValueRange();
2177
+ return min + (1 - y / this.#drawHeight) * (max - min);
2178
+ }
2179
+
2180
+ #sampleAt(t) {
2181
+ return this.#waves.reduce((sum, wave) => {
2182
+ const cycleT = t * wave.frequency;
2183
+ const value = FigInputOscillator.#waveValue(wave.type, cycleT, wave.phase);
2184
+ return sum + wave.offset + value * wave.amplitude;
2185
+ }, 0);
2186
+ }
2187
+
2188
+ #updateWaveform() {
2189
+ if (!this.#svg || !this.#path) return;
2190
+ this.#syncViewportSize();
2191
+
2192
+ if (this.#bounds) {
2193
+ this.#bounds.setAttribute("width", this.#drawWidth);
2194
+ this.#bounds.setAttribute("height", this.#drawHeight);
2195
+ }
2196
+
2197
+ const maxFrequency = Math.max(...this.#waves.map((wave) => wave.frequency));
2198
+ const hasSharpWave = this.#waves.some(
2199
+ (wave) => wave.type === "square" || wave.type === "sawtooth",
2200
+ );
2201
+ const samples = hasSharpWave
2202
+ ? Math.max(192, Math.ceil(maxFrequency * 64))
2203
+ : Math.max(96, Math.ceil(maxFrequency * 64));
2204
+ let d = "";
2205
+ for (let i = 0; i <= samples; i++) {
2206
+ const t = i / samples;
2207
+ const x = t * this.#drawWidth;
2208
+ const y = this.#toY(this.#sampleAt(t));
2209
+ d += `${i === 0 ? "M" : "L"}${x.toFixed(1)},${y.toFixed(1)}`;
2210
+ }
2211
+ this.#path.setAttribute("d", d);
2212
+
2213
+ const baselineY = this.#toY(0);
2214
+ this.#baseline?.setAttribute("x1", "0");
2215
+ this.#baseline?.setAttribute("y1", baselineY);
2216
+ this.#baseline?.setAttribute("x2", this.#drawWidth);
2217
+ this.#baseline?.setAttribute("y2", baselineY);
2218
+
2219
+ this.#positionHandles();
2220
+ }
2221
+
2222
+ #startPlayhead() {
2223
+ if (this.#playheadFrame || !this.#playhead) return;
2224
+
2225
+ const tick = (time) => {
2226
+ if (!this.isConnected || !this.#playhead) {
2227
+ this.#playheadFrame = 0;
2228
+ return;
2229
+ }
2230
+ this.#updatePlayhead((time % 1000) / 1000);
2231
+ this.#playheadFrame = requestAnimationFrame(tick);
2232
+ };
2233
+
2234
+ this.#playheadFrame = requestAnimationFrame(tick);
2235
+ }
2236
+
2237
+ #stopPlayhead() {
2238
+ if (!this.#playheadFrame) return;
2239
+ cancelAnimationFrame(this.#playheadFrame);
2240
+ this.#playheadFrame = 0;
2241
+ }
2242
+
2243
+ #updatePlayhead(t) {
2244
+ if (!this.#playhead) return;
2245
+ const x = t * this.#drawWidth;
2246
+ const y = this.#toY(this.#sampleAt(t));
2247
+ this.#playhead.setAttribute("cx", x.toFixed(1));
2248
+ this.#playhead.setAttribute("cy", y.toFixed(1));
2249
+ }
2250
+
2251
+ #positionHandles() {
2252
+ const radius = 8;
2253
+ const wave = this.#activeWave;
2254
+ const amplitudeX = this.#getAmplitudeHandleX();
2255
+ const frequencyX = Math.max(
2256
+ radius,
2257
+ Math.min(this.#drawWidth - radius, this.#drawWidth / wave.frequency),
2258
+ );
2259
+ const amplitudeY = this.#toY(this.#sampleAt(amplitudeX / this.#drawWidth));
2260
+ const frequencyY = this.#toY(this.#sampleAt(frequencyX / this.#drawWidth));
2261
+
2262
+ this.#setHandlePosition(this.#handleAmplitude, amplitudeX, amplitudeY, radius);
2263
+ this.#setHandlePosition(this.#handleFrequency, frequencyX, frequencyY, radius);
2264
+ }
2265
+
2266
+ #getAmplitudeHandleX() {
2267
+ const wave = this.#activeWave;
2268
+ const phaseCycle = wave.phase / 360;
2269
+ const frequency = Math.max(0.1, wave.frequency);
2270
+ let targetCycle = 0.25;
2271
+ if (wave.type === "triangle") targetCycle = 0.5;
2272
+ if (wave.type === "sawtooth") targetCycle = 1;
2273
+ let t = (targetCycle - phaseCycle) / frequency;
2274
+
2275
+ while (t < 0) t += 1 / frequency;
2276
+ while (t > 1) t -= 1 / frequency;
2277
+
2278
+ if (t < 0 || t > 1 || !Number.isFinite(t)) {
2279
+ t = 0.25;
2280
+ }
2281
+
2282
+ return Math.max(8, Math.min(this.#drawWidth - 8, t * this.#drawWidth));
2283
+ }
2284
+
2285
+ #setHandlePosition(handle, x, y, radius) {
2286
+ if (!handle) return;
2287
+ handle.setAttribute("x", x - radius);
2288
+ handle.setAttribute("y", y - radius);
2289
+ handle.setAttribute("width", radius * 2);
2290
+ handle.setAttribute("height", radius * 2);
2291
+ }
2292
+
2293
+ #setupEvents() {
2294
+ for (const typeControl of this.#typeControls) {
2295
+ typeControl.addEventListener("change", (event) => {
2296
+ if (this.#isDisabled()) return;
2297
+ const index = this.#indexFromElement(typeControl);
2298
+ this.#setActiveWave(index);
2299
+ this.#waves[index].type = this.#normalizeType(
2300
+ event.detail ?? event.target?.value,
2301
+ );
2302
+ this.#syncUI();
2303
+ this.#emit("input");
2304
+ this.#emit("change");
2305
+ });
2306
+ }
2307
+
2308
+ for (const field of this.#fields) {
2309
+ field.addEventListener("input", (event) => {
2310
+ event.stopPropagation();
2311
+ this.#applyFieldValue(
2312
+ this.#indexFromElement(field),
2313
+ field.getAttribute("name"),
2314
+ event.detail ?? event.currentTarget?.value ?? event.target?.value,
2315
+ "input",
2316
+ );
2317
+ });
2318
+ field.addEventListener("change", (event) => {
2319
+ event.stopPropagation();
2320
+ this.#applyFieldValue(
2321
+ this.#indexFromElement(field),
2322
+ field.getAttribute("name"),
2323
+ event.detail ?? event.currentTarget?.value ?? event.target?.value,
2324
+ "change",
2325
+ );
2326
+ });
2327
+ }
2328
+
2329
+ for (const row of this.#waveRows) {
2330
+ row.addEventListener("pointerdown", () => {
2331
+ this.#setActiveWave(this.#indexFromElement(row));
2332
+ });
2333
+ row.addEventListener("focusin", () => {
2334
+ this.#setActiveWave(this.#indexFromElement(row));
2335
+ });
2336
+ }
2337
+
2338
+ this.#addTypeControl?.addEventListener("change", (event) => {
2339
+ if (this.#isDisabled()) return;
2340
+ if (event.target !== this.#addTypeControl) return;
2341
+ const type = this.#normalizeType(event.detail ?? this.#addTypeControl?.value);
2342
+ this.#waves.push(FigInputOscillator.#defaultWave(type));
2343
+ this.#activeWaveIndex = this.#waves.length - 1;
2344
+ this.#render();
2345
+ this.#emit("input");
2346
+ this.#emit("change");
2347
+ });
2348
+
2349
+ for (const button of this.querySelectorAll(".fig-input-oscillator-remove-button")) {
2350
+ button.addEventListener("click", () => {
2351
+ if (this.#isDisabled() || this.#waves.length <= 1) return;
2352
+ const index = this.#indexFromElement(button);
2353
+ this.#waves.splice(index, 1);
2354
+ this.#activeWaveIndex = Math.min(this.#activeWaveIndex, this.#waves.length - 1);
2355
+ this.#render();
2356
+ this.#emit("input");
2357
+ this.#emit("change");
2358
+ });
2359
+ }
2360
+
2361
+ for (const handle of [
2362
+ this.#handleAmplitude,
2363
+ this.#handleFrequency,
2364
+ ]) {
2365
+ this.#setupHandle(handle);
2366
+ }
2367
+
2368
+ const surface = this.querySelector(".fig-input-oscillator-svg-container");
2369
+ surface?.addEventListener("pointerdown", (event) => {
2370
+ if (this.#isDisabled()) return;
2371
+ if (event.target?.closest?.(".fig-input-oscillator-handle, fig-handle")) {
2372
+ return;
2373
+ }
2374
+ this.#startDrag(event, "offset");
2375
+ });
2376
+ }
2377
+
2378
+ #indexFromElement(element) {
2379
+ const index = Number.parseInt(element?.getAttribute("data-wave-index") || "", 10);
2380
+ return Number.isFinite(index) ? Math.min(this.#waves.length - 1, Math.max(0, index)) : 0;
2381
+ }
2382
+
2383
+ #setupHandle(handleContainer) {
2384
+ const handle = handleContainer?.querySelector("fig-handle");
2385
+ const type = handleContainer?.getAttribute("data-handle");
2386
+ if (!handle || !type) return;
2387
+
2388
+ handle.addEventListener(
2389
+ "pointerdown",
2390
+ (event) => {
2391
+ if (this.#isDisabled()) return;
2392
+ event.preventDefault();
2393
+ event.stopImmediatePropagation();
2394
+ this.#startDrag(event, type);
2395
+ },
2396
+ { capture: true },
2397
+ );
2398
+
2399
+ handle.addEventListener(
2400
+ "keydown",
2401
+ (event) => {
2402
+ if (this.#isDisabled()) return;
2403
+ if (
2404
+ !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(
2405
+ event.key,
2406
+ )
2407
+ ) {
2408
+ return;
2409
+ }
2410
+ if (!this.#handleKeyboard(event, type)) return;
2411
+ event.preventDefault();
2412
+ event.stopImmediatePropagation();
2413
+ },
2414
+ { capture: true },
2415
+ );
2416
+ }
2417
+
2418
+ #setActiveWave(index) {
2419
+ const nextIndex = Math.min(this.#waves.length - 1, Math.max(0, index));
2420
+ if (nextIndex === this.#activeWaveIndex) return;
2421
+ this.#activeWaveIndex = nextIndex;
2422
+ this.#syncActiveWave();
2423
+ this.#positionHandles();
2424
+ }
2425
+
2426
+ #applyFieldValue(index, name, value, eventType) {
2427
+ if (this.#isDisabled()) return;
2428
+ this.#setActiveWave(index);
2429
+ const next = Number.parseFloat(value);
2430
+ if (!Number.isFinite(next)) {
2431
+ if (eventType === "change") this.#syncFields();
2432
+ return;
2433
+ }
2434
+
2435
+ this.#waves[index] = this.#normalizeWave({
2436
+ ...this.#waves[index],
2437
+ [name]: next,
2438
+ });
2439
+ this.#syncUI();
2440
+ this.#emit(eventType);
2441
+ }
2442
+
2443
+ #handleKeyboard(event, type) {
2444
+ const step = event.shiftKey ? 0.5 : 0.1;
2445
+ const wave = this.#activeWave;
2446
+ switch (type) {
2447
+ case "amplitude":
2448
+ if (event.key === "ArrowUp") wave.amplitude += step;
2449
+ else if (event.key === "ArrowDown") wave.amplitude -= step;
2450
+ else if (event.key === "Home") wave.amplitude = -4;
2451
+ else if (event.key === "End") wave.amplitude = 4;
2452
+ else return false;
2453
+ break;
2454
+ case "offset":
2455
+ if (event.key === "ArrowUp") wave.offset += step;
2456
+ else if (event.key === "ArrowDown") wave.offset -= step;
2457
+ else if (event.key === "Home") wave.offset = -4;
2458
+ else if (event.key === "End") wave.offset = 4;
2459
+ else return false;
2460
+ break;
2461
+ case "frequency":
2462
+ if (event.key === "ArrowLeft") wave.frequency -= step;
2463
+ else if (event.key === "ArrowRight") wave.frequency += step;
2464
+ else if (event.key === "Home") wave.frequency = 0.1;
2465
+ else if (event.key === "End") wave.frequency = 16;
2466
+ else return false;
2467
+ break;
2468
+ default:
2469
+ return false;
2470
+ }
2471
+
2472
+ this.#waves[this.#activeWaveIndex] = this.#normalizeWave(wave);
2473
+ this.#syncUI();
2474
+ this.#emit("input");
2475
+ this.#emit("change");
2476
+ return true;
2477
+ }
2478
+
2479
+ #clientToSVG(event) {
2480
+ const ctm = this.#svg?.getScreenCTM();
2481
+ if (!ctm) return { x: 0, y: 0 };
2482
+ const inv = ctm.inverse();
2483
+ return {
2484
+ x: inv.a * event.clientX + inv.c * event.clientY + inv.e,
2485
+ y: inv.b * event.clientX + inv.d * event.clientY + inv.f,
2486
+ };
2487
+ }
2488
+
2489
+ #startDrag(event, type) {
2490
+ this.#isDragging = type;
2491
+
2492
+ const onMove = (moveEvent) => {
2493
+ if (!this.#isDragging) return;
2494
+ const point = this.#clientToSVG(moveEvent);
2495
+ const wave = this.#activeWave;
2496
+
2497
+ if (type === "frequency") {
2498
+ const x = Math.max(1, Math.min(this.#drawWidth, point.x));
2499
+ wave.frequency = this.#drawWidth / x;
2500
+ } else if (type === "offset") {
2501
+ const t = this.#clampNumber(point.x / this.#drawWidth, 0, 0, 1);
2502
+ const activeValue = this.#activeWaveValueAt(wave, t);
2503
+ wave.offset =
2504
+ this.#fromY(point.y) -
2505
+ this.#sampleAtWithoutWave(this.#activeWaveIndex, t) -
2506
+ activeValue * wave.amplitude;
2507
+ } else if (type === "amplitude") {
2508
+ const t = this.#clampNumber(point.x / this.#drawWidth, 0, 0, 1);
2509
+ const activeValue = this.#activeWaveValueAt(wave, t);
2510
+ const nextAmplitude =
2511
+ this.#fromY(point.y) -
2512
+ this.#sampleAtWithoutWave(this.#activeWaveIndex, t) -
2513
+ wave.offset;
2514
+ wave.amplitude =
2515
+ Math.abs(activeValue) < 0.001 ? wave.amplitude : nextAmplitude / activeValue;
2516
+ }
2517
+
2518
+ this.#waves[this.#activeWaveIndex] = this.#normalizeWave(wave);
2519
+ this.#syncUI();
2520
+ this.#emit("input");
2521
+ };
2522
+
2523
+ const onUp = () => {
2524
+ this.#isDragging = null;
2525
+ document.removeEventListener("pointermove", onMove);
2526
+ document.removeEventListener("pointerup", onUp);
2527
+ this.#emit("change");
2528
+ };
2529
+
2530
+ document.addEventListener("pointermove", onMove);
2531
+ document.addEventListener("pointerup", onUp);
2532
+ }
2533
+
2534
+ #sampleAtWithoutWave(excludedIndex, t) {
2535
+ return this.#waves.reduce((sum, wave, index) => {
2536
+ if (index === excludedIndex) return sum;
2537
+ const cycleT = t * wave.frequency;
2538
+ const value = FigInputOscillator.#waveValue(wave.type, cycleT, wave.phase);
2539
+ return sum + wave.offset + value * wave.amplitude;
2540
+ }, 0);
2541
+ }
2542
+
2543
+ #activeWaveValueAt(wave, t) {
2544
+ return FigInputOscillator.#waveValue(
2545
+ wave.type,
2546
+ t * wave.frequency,
2547
+ wave.phase,
2548
+ );
2549
+ }
2550
+
2551
+ #syncUI() {
2552
+ this.#syncTypeControls();
2553
+ this.#syncFields();
2554
+ this.#syncActiveWave();
2555
+ this.#updateWaveform();
2556
+ }
2557
+
2558
+ #syncTypeControls() {
2559
+ for (const control of this.#typeControls) {
2560
+ const index = this.#indexFromElement(control);
2561
+ control.value = this.#waves[index]?.type || "sine";
2562
+ }
2563
+ }
2564
+
2565
+ #syncFields() {
2566
+ for (const field of this.#fields) {
2567
+ const index = this.#indexFromElement(field);
2568
+ const name = field.getAttribute("name");
2569
+ field.setAttribute("value", this.#round(this.#waves[index]?.[name] ?? 0));
2570
+ }
2571
+ }
2572
+
2573
+ #syncActiveWave() {
2574
+ for (const row of this.#waveRows) {
2575
+ if (this.#indexFromElement(row) === this.#activeWaveIndex) {
2576
+ row.setAttribute("data-active", "");
2577
+ } else {
2578
+ row.removeAttribute("data-active");
2579
+ }
2580
+ }
2581
+ }
2582
+
2583
+ #emit(type) {
2584
+ this.dispatchEvent(
2585
+ new CustomEvent(type, {
2586
+ bubbles: true,
2587
+ detail: {
2588
+ value: this.value,
2589
+ data: this.data,
2590
+ preset: FigInputOscillator.#labelForType(this.#activeWave.type),
2591
+ },
2592
+ }),
2593
+ );
2594
+ }
2595
+ }
2596
+ customElements.define("fig-input-oscillator", FigInputOscillator);
2597
+
1777
2598
  /* Angle Input */
1778
2599
  /**
1779
2600
  * A custom angle chooser input element.