1// ============================================================================= 2// shared/VariationAxes.ts 3// ============================================================================= 4import { saveInstanceIndex, getSavedInstanceIndex, saveAxisSettings, getSavedAxisSettings, saveLastChanged, getSavedLastChanged, } from './FontSession.js'; 5import { VARIATION_DEFAULTS } from '../core/Config.js'; 6export class VariationAxes { 7 container; 8 onChange; 9 axisContainer; 10 currentSettings = {}; 11 sliderMap = {}; 12 _instanceSelect = null; 13 _instances = []; 14 constructor(options) { 15 this.container = options.container; 16 this.onChange = options.onChange; 17 this.axisContainer = document.createElement('div'); 18 this.axisContainer.className = 'axis-controls'; 19 this.container.appendChild(this.axisContainer); 20 } 21 /** 22 * Creates sliders for variable-font axes and an optional named-instances 23 * dropdown. 24 */ 25 createAxesControls(axes, instances = []) { 26 this.axisContainer.innerHTML = ''; 27 this.currentSettings = {}; 28 this.sliderMap = {}; 29 // Remove any previously-inserted instances row. 30 this.container.querySelector('.instances-container')?.remove(); 31 // ---- Named instances dropdown ------------------------------------------ 32 if (instances.length > 0) { 33 const instancesRow = document.createElement('div'); 34 instancesRow.className = 'slider-container instances-container'; 35 const label = document.createElement('label'); 36 label.textContent = 'Named instances'; 37 label.htmlFor = 'font-instances'; 38 const select = document.createElement('select'); 39 select.className = 'instances-select'; 40 select.id = 'font-instances'; 41 select.name = 'font-instances'; 42 instances.forEach((inst, i) => { 43 const option = document.createElement('option'); 44 option.value = String(i); 45 option.textContent = inst.name; 46 select.appendChild(option); 47 }); 48 select.addEventListener('change', () => { 49 const idx = parseInt(select.value, 10); 50 if (!isNaN(idx) && idx < instances.length) { 51 saveInstanceIndex(idx); 52 saveLastChanged('instance'); 53 this.setAxesValues(instances[idx].coordinates); 54 saveAxisSettings({ ...this.currentSettings }); 55 } 56 }); 57 instancesRow.appendChild(label); 58 instancesRow.appendChild(select); 59 const styleContainer = this.container.querySelector('.style-buttons-wrapper')?.closest('.buttons-container'); 60 if (styleContainer?.nextSibling) { 61 this.container.insertBefore(instancesRow, styleContainer.nextSibling); 62 } 63 else if (styleContainer) { 64 this.container.appendChild(instancesRow); 65 } 66 else { 67 const firstSlider = this.container.querySelector('.slider-container'); 68 if (firstSlider) { 69 this.container.insertBefore(instancesRow, firstSlider); 70 } 71 else { 72 this.container.insertBefore(instancesRow, this.axisContainer); 73 } 74 } 75 this._instanceSelect = select; 76 this._instances = instances; 77 } 78 else { 79 this._instanceSelect = null; 80 this._instances = []; 81 } 82 // ---- Axis sliders ------------------------------------------------------- 83 for (const axis of axes) { 84 const row = document.createElement('div'); 85 row.className = 'slider-container variation-axis'; 86 const sliderId = `axis-${axis.tag}`; 87 const label = document.createElement('label'); 88 label.textContent = `${axis.name} (${axis.tag})`; 89 label.htmlFor = sliderId; 90 const slider = document.createElement('input'); 91 slider.type = 'range'; 92 slider.id = sliderId; 93 if (axis.avarMap) { 94 // Operate in avar-output space so the slider moves linearly through 95 // the design space rather than linearly through raw axis values. 96 const sliderMin = this._avarForward(this._normAxis(axis.min, axis), axis.avarMap); 97 const sliderMax = this._avarForward(this._normAxis(axis.max, axis), axis.avarMap); 98 const sliderDefault = this._avarForward(this._normAxis(axis.default, axis), axis.avarMap); 99 slider.min = String(sliderMin); 100 slider.max = String(sliderMax);
101 slider.value = String(sliderDefault); 102 slider.step = '0.001'; 103 } 104 else { 105 slider.min = String(axis.min); 106 slider.max = String(axis.max); 107 slider.value = String(axis.default); 108 slider.step = '0.1'; 109 } 110 const valueSpan = document.createElement('span'); 111 valueSpan.className = 'value'; 112 valueSpan.textContent = parseFloat(String(axis.default)).toFixed(1); 113 if (axis.avarMap) { 114 const avarMap = axis.avarMap; 115 slider.addEventListener('input', () => { 116 const sliderVal = parseFloat(slider.value); 117 const inputNorm = this._avarInverse(sliderVal, avarMap); 118 const actualVal = this._denormAxis(inputNorm, axis); 119 this._updateAxisValue(axis.tag, String(actualVal)); 120 valueSpan.textContent = actualVal.toFixed(1); 121 }); 122 } 123 else { 124 slider.addEventListener('input', () => { 125 this._updateAxisValue(axis.tag, slider.value); 126 valueSpan.textContent = parseFloat(slider.value).toFixed(1); 127 }); 128 } 129 this.currentSettings[axis.tag] = parseFloat(String(axis.default)); 130 this.sliderMap[axis.tag] = { slider, valueSpan, axis }; 131 row.appendChild(label); 132 row.appendChild(slider); 133 row.appendChild(valueSpan); 134 this.axisContainer.appendChild(row); 135 } 136 // ---- Restore persisted state -------------------------------------------- 137 const lastChanged = getSavedLastChanged(); 138 const savedAxisSettings = getSavedAxisSettings(); 139 // 1. User has previously moved sliders â restore those. 140 if (lastChanged === 'sliders' && savedAxisSettings) { 141 this.setAxesValues(savedAxisSettings); 142 return; 143 } 144 // 2. User has previously selected a named instance â restore that. 145 if (this._instanceSelect && this._instances.length > 0) { 146 const savedIdx = getSavedInstanceIndex(); 147 if (savedIdx !== null && savedIdx < this._instances.length) { 148 this._instanceSelect.value = String(savedIdx); 149 this.setAxesValues(this._instances[savedIdx].coordinates); 150 return; 151 } 152 } 153 // 3. First visit â apply VARIATION_DEFAULTS from Config.ts. 154 const configInstance = VARIATION_DEFAULTS.namedInstance; 155 const configAxes = VARIATION_DEFAULTS.axes; 156 const hasAxisOverrides = Object.values(configAxes).some(v => v !== null); 157 if (configInstance !== null && this._instances.length > 0) { 158 const idx = this._findInstanceIndex(configInstance); 159 if (idx >= 0) { 160 if (this._instanceSelect) 161 this._instanceSelect.value = String(idx); 162 const coords = { ...this._instances[idx].coordinates }; 163 for (const [tag, val] of Object.entries(configAxes)) { 164 if (val !== null) 165 coords[tag] = val; 166 } 167 this.setAxesValues(coords); 168 return; 169 } 170 } 171 if (hasAxisOverrides) { 172 const coords = { ...this.currentSettings }; 173 for (const [tag, val] of Object.entries(configAxes)) { 174 if (val !== null) 175 coords[tag] = val; 176 } 177 this.setAxesValues(coords); 178 return; 179 } 180 // Emit initial settings from axis defaults. 181 this._updateVariationSettings(); 182 } 183 /** 184 * Sets all axis sliders to the supplied coordinate values and triggers the 185 * onChange callback. 186 */ 187 setAxesValues(coordinates) { 188 for (const [tag, val] of Object.entries(coordinates)) { 189 this.currentSettings[tag] = val; 190 const entry = this.sliderMap[tag]; 191 if (entry) { 192 if (entry.axis.avarMap) { 193 // Convert the actual axis value â normalised â avar-output space. 194 const norm = this._normAxis(val, entry.axis); 195 const sliderVal = this._avarForward(norm, entry.axis.avarMap);
196 entry.slider.value = String(sliderVal); 197 } 198 else { 199 entry.slider.value = String(val); 200 } 201 entry.valueSpan.textContent = parseFloat(String(val)).toFixed(1); 202 } 203 } 204 this._updateVariationSettings(); 205 } 206 _updateAxisValue(tag, value) { 207 const numValue = parseFloat(value); 208 if (isNaN(numValue)) { 209 delete this.currentSettings[tag]; 210 } 211 else { 212 this.currentSettings[tag] = numValue; 213 } 214 saveAxisSettings({ ...this.currentSettings }); 215 saveLastChanged('sliders'); 216 this._updateVariationSettings(); 217 } 218 _updateVariationSettings() { 219 const settings = Object.entries(this.currentSettings) 220 .filter(([, val]) => !isNaN(val)) 221 .map(([tag, val]) => `"${tag}" ${val.toFixed(1)}`) 222 .join(', '); 223 this.onChange(settings || 'normal'); 224 } 225 // --------------------------------------------------------------------------- 226 // Avar / normalisation helpers 227 // --------------------------------------------------------------------------- 228 /** 229 * Normalise an actual axis value to the [-1, 1] coordinate space used by 230 * the avar table, following the OpenType fvar normalisation rules. 231 */ 232 _normAxis(value, axis) { 233 if (value === axis.default) 234 return 0; 235 if (value > axis.default) { 236 return axis.max === axis.default 237 ? 0 238 : (value - axis.default) / (axis.max - axis.default); 239 } 240 // value < axis.default 241 return axis.min === axis.default 242 ? 0 243 : (value - axis.default) / (axis.default - axis.min); 244 } 245 /** 246 * Denormalise from the [-1, 1] coordinate space back to an actual axis 247 * value. 248 */ 249 _denormAxis(norm, axis) { 250 if (norm === 0) 251 return axis.default; 252 if (norm > 0) 253 return axis.default + norm * (axis.max - axis.default); 254 /* norm < 0 */ return axis.default + norm * (axis.default - axis.min); 255 } 256 /** 257 * Evaluate the avar piecewise-linear segment map in the forward direction 258 * (fvar-normalised input â avar-remapped output). 259 */ 260 _avarForward(input, map) { 261 if (map.length === 0) 262 return input; 263 const first = map[0]; 264 const last = map[map.length - 1]; 265 if (input <= first.fromCoordinate) 266 return first.toCoordinate; 267 if (input >= last.fromCoordinate) 268 return last.toCoordinate; 269 for (let i = 0; i < map.length - 1; i++) { 270 const p0 = map[i]; 271 const p1 = map[i + 1]; 272 if (input >= p0.fromCoordinate && input <= p1.fromCoordinate) { 273 const dFrom = p1.fromCoordinate - p0.fromCoordinate; 274 if (dFrom === 0) 275 return p0.toCoordinate; 276 const t = (input - p0.fromCoordinate) / dFrom; 277 return p0.toCoordinate + t * (p1.toCoordinate - p0.toCoordinate); 278 } 279 } 280 return input; 281 } 282 /** 283 * Evaluate the avar piecewise-linear segment map in the inverse direction 284 * (avar-remapped output â fvar-normalised input). Requires the 285 * toCoordinates to be monotonically non-decreasing, which is guaranteed 286 * for a well-formed avar table. 287 */ 288 _avarInverse(output, map) { 289 if (map.length === 0) 290 return output; 291 const first = map[0]; 292 const last = map[map.length - 1]; 293 if (output <= first.toCoordinate) 294 return first.fromCoordinate; 295 if (output >= last.toCoordinate) 296 return last.fromCoordinate; 297 for (let i = 0; i < map.length - 1; i++) { 298 const p0 = map[i]; 299 const p1 = map[i + 1]; 300 if (output >= p0.toCoordinate && output <= p1.toCoordinate) { 301 const dTo = p1.toCoordinate - p0.toCoordinate; 302 if (dTo === 0) 303 return p0.fromCoordinate; 304 const t = (output - p0.toCoordinate) / dTo; 305 return p0.fromCoordinate + t * (p1.fromCoordinate - p0.fromCoordinate); 306 } 307 } 308 return output; 309 } 310 /** 311 * Finds the index of a named instance by name, with a fallback that strips 312 * style particles (e.g. ' Italic', ' Oblique') from the end of the name so 313 * that 'SubHead SemiExp SemiBold Italic' matches 'SubHead SemiExp SemiBold' 314 * when switching to a Roman font file. 315 */ 316 _findInstanceIndex(name) { 317 // 1. Exact match. 318 const exact = this._instances.findIndex(inst => inst.name === name); 319 if (exact >= 0) 320 return exact; 321 // 2. Strip trailing style particles and try again. 322 const STYLE_SUFFIXES = [' Italic', ' Oblique', ' Ital']; 323 for (const suffix of STYLE_SUFFIXES) { 324 if (name.endsWith(suffix)) { 325 const stripped = name.slice(0, -suffix.length).trimEnd(); 326 const fallback = this._instances.findIndex(inst => inst.name === stripped); 327 if (fallback >= 0) 328 return fallback; 329 } 330 } 331 return -1; 332 } 333} 334//# sourceMappingURL=VariationAxes.js.map
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.