refactor: split color-strips.ts into focused modules under color-strips/ folder
Lint & Test / test (push) Successful in 2m6s
Lint & Test / test (push) Successful in 2m6s
Monolithic 3060-line color-strips.ts split into 11 modules: - index.ts (core orchestrator, modal, type switching, editor, save, CRUD) - cards.ts (card rendering for all source types) - game-event.ts (game event mappings, presets, UI) - gradient.ts (gradient entity modal + CRUD) - audio.ts (audio viz widgets, load/reset state) - math-wave.ts (wave layers + waveform selects) - mapped.ts (mapped zone helpers) - color-cycle.ts (color cycle add/remove/render) - composite.ts, notification.ts, test.ts (previously extracted, moved into folder)
This commit is contained in:
@@ -145,7 +145,7 @@ import {
|
|||||||
testColorStrip, testCSPT, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer,
|
testColorStrip, testCSPT, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer,
|
||||||
addCSSGameMapping, removeCSSGameMapping, onCSSGameMappingPresetChange,
|
addCSSGameMapping, removeCSSGameMapping, onCSSGameMappingPresetChange,
|
||||||
mathWaveAddLayer, mathWaveRemoveLayer,
|
mathWaveAddLayer, mathWaveRemoveLayer,
|
||||||
} from './features/color-strips.ts';
|
} from './features/color-strips/index.ts';
|
||||||
|
|
||||||
// Layer 5: audio sources
|
// Layer 5: audio sources
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { t } from '../core/i18n.ts';
|
|||||||
import { showToast } from '../core/ui.ts';
|
import { showToast } from '../core/ui.ts';
|
||||||
import { Modal } from '../core/modal.ts';
|
import { Modal } from '../core/modal.ts';
|
||||||
import { closeTutorial, startCalibrationTutorial } from './tutorials.ts';
|
import { closeTutorial, startCalibrationTutorial } from './tutorials.ts';
|
||||||
import { startCSSOverlay, stopCSSOverlay } from './color-strips.ts';
|
import { startCSSOverlay, stopCSSOverlay } from './color-strips/index.ts';
|
||||||
import { ICON_WARNING, ICON_ROTATE_CW, ICON_ROTATE_CCW } from '../core/icons.ts';
|
import { ICON_WARNING, ICON_ROTATE_CW, ICON_ROTATE_CCW } from '../core/icons.ts';
|
||||||
import type { Calibration } from '../types.ts';
|
import type { Calibration } from '../types.ts';
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,207 @@
|
|||||||
|
/**
|
||||||
|
* Color Strip Sources — Audio visualization helpers.
|
||||||
|
* Extracted from color-strips.ts to reduce file size.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { escapeHtml } from '../../core/api.ts';
|
||||||
|
import { _cachedValueSources, audioSourcesCache } from '../../core/state.ts';
|
||||||
|
import { t } from '../../core/i18n.ts';
|
||||||
|
import { getAudioSourceIcon } from '../../core/icons.ts';
|
||||||
|
import * as P from '../../core/icon-paths.ts';
|
||||||
|
import { IconSelect } from '../../core/icon-select.ts';
|
||||||
|
import { EntitySelect } from '../../core/entity-palette.ts';
|
||||||
|
import { BindableScalarWidget } from '../../core/bindable-scalar.ts';
|
||||||
|
import { BindableColorWidget } from '../../core/bindable-color.ts';
|
||||||
|
|
||||||
|
const _icon = (d: any) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
||||||
|
|
||||||
|
/* ── State ───────────────────────────────────────��────────────── */
|
||||||
|
|
||||||
|
let _audioSensitivityWidget: BindableScalarWidget | null = null;
|
||||||
|
let _audioSmoothingWidget: BindableScalarWidget | null = null;
|
||||||
|
let _audioBeatDecayWidget: BindableScalarWidget | null = null;
|
||||||
|
let _audioColorWidget: BindableColorWidget | null = null;
|
||||||
|
let _audioColorPeakWidget: BindableColorWidget | null = null;
|
||||||
|
let _cssAudioSourceEntitySelect: any = null;
|
||||||
|
let _audioVizIconSelect: any = null;
|
||||||
|
let _audioPaletteEntitySelect: EntitySelect | null = null;
|
||||||
|
|
||||||
|
/* ── Destroy (called from modal onForceClose) ─────────────────── */
|
||||||
|
|
||||||
|
export function destroyAudioWidgets() {
|
||||||
|
if (_audioSensitivityWidget) { _audioSensitivityWidget.destroy(); _audioSensitivityWidget = null; }
|
||||||
|
if (_audioSmoothingWidget) { _audioSmoothingWidget.destroy(); _audioSmoothingWidget = null; }
|
||||||
|
if (_audioColorWidget) { _audioColorWidget.destroy(); _audioColorWidget = null; }
|
||||||
|
if (_audioColorPeakWidget) { _audioColorPeakWidget.destroy(); _audioColorPeakWidget = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Widget ensure helpers ──────────────────────────────────���─── */
|
||||||
|
|
||||||
|
export function ensureAudioSensitivityWidget(): BindableScalarWidget {
|
||||||
|
if (!_audioSensitivityWidget) {
|
||||||
|
_audioSensitivityWidget = new BindableScalarWidget({
|
||||||
|
container: document.getElementById('css-editor-audio-sensitivity-container')!,
|
||||||
|
min: 0.1, max: 5.0, step: 0.1, default: 1.0,
|
||||||
|
idPrefix: 'css-editor-audio-sensitivity',
|
||||||
|
valueSources: () => _cachedValueSources,
|
||||||
|
format: (v) => v.toFixed(1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _audioSensitivityWidget;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureAudioSmoothingWidget(): BindableScalarWidget {
|
||||||
|
if (!_audioSmoothingWidget) {
|
||||||
|
_audioSmoothingWidget = new BindableScalarWidget({
|
||||||
|
container: document.getElementById('css-editor-audio-smoothing-container')!,
|
||||||
|
min: 0.0, max: 1.0, step: 0.05, default: 0.3,
|
||||||
|
idPrefix: 'css-editor-audio-smoothing',
|
||||||
|
valueSources: () => _cachedValueSources,
|
||||||
|
format: (v) => v.toFixed(2),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _audioSmoothingWidget;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureAudioBeatDecayWidget(): BindableScalarWidget {
|
||||||
|
if (!_audioBeatDecayWidget) {
|
||||||
|
_audioBeatDecayWidget = new BindableScalarWidget({
|
||||||
|
container: document.getElementById('css-editor-audio-beat-decay-container')!,
|
||||||
|
min: 0.01, max: 0.5, step: 0.01, default: 0.15,
|
||||||
|
idPrefix: 'css-editor-audio-beat-decay',
|
||||||
|
valueSources: () => _cachedValueSources,
|
||||||
|
format: (v) => v.toFixed(2),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _audioBeatDecayWidget;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureAudioColorWidget(): BindableColorWidget {
|
||||||
|
if (!_audioColorWidget) {
|
||||||
|
_audioColorWidget = new BindableColorWidget({
|
||||||
|
container: document.getElementById('css-editor-audio-color-container')!,
|
||||||
|
default: [0, 255, 0],
|
||||||
|
valueSources: () => _cachedValueSources,
|
||||||
|
idPrefix: 'css-editor-audio-color',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _audioColorWidget;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureAudioColorPeakWidget(): BindableColorWidget {
|
||||||
|
if (!_audioColorPeakWidget) {
|
||||||
|
_audioColorPeakWidget = new BindableColorWidget({
|
||||||
|
container: document.getElementById('css-editor-audio-color-peak-container')!,
|
||||||
|
default: [255, 0, 0],
|
||||||
|
valueSources: () => _cachedValueSources,
|
||||||
|
idPrefix: 'css-editor-audio-color-peak',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _audioColorPeakWidget;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── IconSelect / EntitySelect ────────────────────────���───────── */
|
||||||
|
|
||||||
|
export function ensureAudioVizIconSelect() {
|
||||||
|
const sel = document.getElementById('css-editor-audio-viz') as HTMLSelectElement | null;
|
||||||
|
if (!sel) return;
|
||||||
|
const items = [
|
||||||
|
{ value: 'spectrum', icon: _icon(P.activity), label: t('color_strip.audio.viz.spectrum'), desc: t('color_strip.audio.viz.spectrum.desc') },
|
||||||
|
{ value: 'beat_pulse', icon: _icon(P.zap), label: t('color_strip.audio.viz.beat_pulse'), desc: t('color_strip.audio.viz.beat_pulse.desc') },
|
||||||
|
{ value: 'vu_meter', icon: _icon(P.trendingUp), label: t('color_strip.audio.viz.vu_meter'), desc: t('color_strip.audio.viz.vu_meter.desc') },
|
||||||
|
{ value: 'pulse_on_beat', icon: _icon(P.heart), label: t('color_strip.audio.viz.pulse_on_beat'), desc: t('color_strip.audio.viz.pulse_on_beat.desc') },
|
||||||
|
{ value: 'energy_gradient', icon: _icon(P.flame), label: t('color_strip.audio.viz.energy_gradient'), desc: t('color_strip.audio.viz.energy_gradient.desc') },
|
||||||
|
{ value: 'spectrum_bands', icon: _icon(P.radio), label: t('color_strip.audio.viz.spectrum_bands'), desc: t('color_strip.audio.viz.spectrum_bands.desc') },
|
||||||
|
{ value: 'strobe_on_drop', icon: _icon(P.sparkles), label: t('color_strip.audio.viz.strobe_on_drop'), desc: t('color_strip.audio.viz.strobe_on_drop.desc') },
|
||||||
|
];
|
||||||
|
if (_audioVizIconSelect) { _audioVizIconSelect.updateItems(items); return; }
|
||||||
|
_audioVizIconSelect = new IconSelect({ target: sel, items, columns: 4 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureAudioPaletteEntitySelect(buildGradientEntityItems: () => any[], syncSelectOptions: (sel: HTMLSelectElement, items: any[]) => void) {
|
||||||
|
const sel = document.getElementById('css-editor-audio-palette') as HTMLSelectElement | null;
|
||||||
|
if (!sel) return;
|
||||||
|
const items = buildGradientEntityItems();
|
||||||
|
syncSelectOptions(sel, items);
|
||||||
|
if (_audioPaletteEntitySelect) { _audioPaletteEntitySelect.refresh(); return; }
|
||||||
|
_audioPaletteEntitySelect = new EntitySelect({
|
||||||
|
target: sel,
|
||||||
|
getItems: buildGradientEntityItems,
|
||||||
|
placeholder: t('palette.search'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function refreshAudioPaletteEntitySelect() {
|
||||||
|
if (_audioPaletteEntitySelect) _audioPaletteEntitySelect.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAudioVizIconSelect() { return _audioVizIconSelect; }
|
||||||
|
export function getAudioPaletteEntitySelect() { return _audioPaletteEntitySelect; }
|
||||||
|
|
||||||
|
/* ── Audio source loading ─────────────────��───────────────────── */
|
||||||
|
|
||||||
|
export async function loadAudioSources() {
|
||||||
|
const select = document.getElementById('css-editor-audio-source') as HTMLSelectElement | null;
|
||||||
|
if (!select) return;
|
||||||
|
try {
|
||||||
|
const sources: any[] = await audioSourcesCache.fetch();
|
||||||
|
select.innerHTML = sources.map(s => {
|
||||||
|
const badge = s.source_type === 'capture' ? ' [capture]' : ' [processed]';
|
||||||
|
return `<option value="${s.id}">${escapeHtml(s.name)}${badge}</option>`;
|
||||||
|
}).join('');
|
||||||
|
if (sources.length === 0) {
|
||||||
|
select.innerHTML = '';
|
||||||
|
}
|
||||||
|
if (_cssAudioSourceEntitySelect) _cssAudioSourceEntitySelect.destroy();
|
||||||
|
if (sources.length > 0) {
|
||||||
|
_cssAudioSourceEntitySelect = new EntitySelect({
|
||||||
|
target: select,
|
||||||
|
getItems: () => sources.map(s => ({
|
||||||
|
value: s.id,
|
||||||
|
label: s.name,
|
||||||
|
icon: getAudioSourceIcon(s.source_type),
|
||||||
|
desc: s.source_type,
|
||||||
|
})),
|
||||||
|
placeholder: t('palette.search'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
select.innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Load / Reset state ───────────────────────────────────────── */
|
||||||
|
|
||||||
|
export function loadAudioState(css: any) {
|
||||||
|
(document.getElementById('css-editor-audio-viz') as HTMLInputElement).value = css.visualization_mode || 'spectrum';
|
||||||
|
if (_audioVizIconSelect) _audioVizIconSelect.setValue(css.visualization_mode || 'spectrum');
|
||||||
|
|
||||||
|
ensureAudioSensitivityWidget().setValue(css.sensitivity ?? 1.0);
|
||||||
|
ensureAudioSmoothingWidget().setValue(css.smoothing ?? 0.3);
|
||||||
|
ensureAudioBeatDecayWidget().setValue(css.beat_decay ?? 0.15);
|
||||||
|
|
||||||
|
const audioGradientId = css.gradient_id || 'gr_builtin_rainbow';
|
||||||
|
(document.getElementById('css-editor-audio-palette') as HTMLInputElement).value = audioGradientId;
|
||||||
|
if (_audioPaletteEntitySelect) _audioPaletteEntitySelect.setValue(audioGradientId);
|
||||||
|
ensureAudioColorWidget().setValue(css.color);
|
||||||
|
ensureAudioColorPeakWidget().setValue(css.color_peak);
|
||||||
|
(document.getElementById('css-editor-audio-mirror') as HTMLInputElement).checked = css.mirror || false;
|
||||||
|
|
||||||
|
const select = document.getElementById('css-editor-audio-source') as HTMLSelectElement | null;
|
||||||
|
if (select && css.audio_source_id) {
|
||||||
|
select.value = css.audio_source_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetAudioState() {
|
||||||
|
(document.getElementById('css-editor-audio-viz') as HTMLInputElement).value = 'spectrum';
|
||||||
|
if (_audioVizIconSelect) _audioVizIconSelect.setValue('spectrum');
|
||||||
|
ensureAudioSensitivityWidget().setValue(1.0);
|
||||||
|
ensureAudioSmoothingWidget().setValue(0.3);
|
||||||
|
ensureAudioBeatDecayWidget().setValue(0.15);
|
||||||
|
(document.getElementById('css-editor-audio-palette') as HTMLInputElement).value = 'gr_builtin_rainbow';
|
||||||
|
if (_audioPaletteEntitySelect) _audioPaletteEntitySelect.setValue('gr_builtin_rainbow');
|
||||||
|
ensureAudioColorWidget().setValue([0, 255, 0]);
|
||||||
|
ensureAudioColorPeakWidget().setValue([255, 0, 0]);
|
||||||
|
(document.getElementById('css-editor-audio-mirror') as HTMLInputElement).checked = false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
/**
|
||||||
|
* Color Strip Sources — Card rendering for the streams list.
|
||||||
|
* Extracted from color-strips.ts to reduce file size.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { escapeHtml } from '../../core/api.ts';
|
||||||
|
import {
|
||||||
|
_cachedSyncClocks, _cachedCSPTemplates, _cachedWeatherSources,
|
||||||
|
colorStripSourcesCache, gradientsCache, GradientEntity,
|
||||||
|
} from '../../core/state.ts';
|
||||||
|
import { t } from '../../core/i18n.ts';
|
||||||
|
import {
|
||||||
|
getColorStripIcon, getPictureSourceIcon,
|
||||||
|
ICON_CLONE, ICON_EDIT, ICON_CALIBRATION, ICON_OVERLAY,
|
||||||
|
ICON_LED, ICON_PALETTE, ICON_FPS, ICON_MAP_PIN, ICON_MUSIC,
|
||||||
|
ICON_AUDIO_LOOPBACK, ICON_TIMER, ICON_LINK_SOURCE, ICON_FILM,
|
||||||
|
ICON_LINK, ICON_SPARKLES, ICON_ACTIVITY, ICON_CLOCK, ICON_BELL, ICON_TEST,
|
||||||
|
ICON_AUTOMATION, ICON_FAST_FORWARD, ICON_THERMOMETER, ICON_PATTERN_TEMPLATE,
|
||||||
|
} from '../../core/icons.ts';
|
||||||
|
import { wrapCard } from '../../core/card-colors.ts';
|
||||||
|
import type { ColorStripSource } from '../../types.ts';
|
||||||
|
import { bindableValue, bindableColor } from '../../types.ts';
|
||||||
|
import { renderTagChips } from '../../core/tag-input.ts';
|
||||||
|
import { rgbArrayToHex } from '../css-gradient-editor.ts';
|
||||||
|
|
||||||
|
/* ── Types ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
type CardPropsRenderer = (source: ColorStripSource, opts: {
|
||||||
|
clockBadge: string;
|
||||||
|
animBadge: string;
|
||||||
|
audioSourceMap: Record<string, any>;
|
||||||
|
pictureSourceMap: Record<string, any>;
|
||||||
|
}) => string;
|
||||||
|
|
||||||
|
/* ── Gradient helpers ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function _getGradients(): GradientEntity[] {
|
||||||
|
return gradientsCache.data || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function _gradientEntityStripHTML(stops: Array<{ position: number; color: number[] }>, w = 80, h = 16) {
|
||||||
|
const css = stops.map(s => `rgb(${s.color.join(',')}) ${(s.position * 100).toFixed(0)}%`).join(', ');
|
||||||
|
return `<span style="display:inline-block;width:${w}px;height:${h}px;border-radius:3px;background:linear-gradient(to right,${css});flex-shrink:0"></span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Non-picture types set ────────────────────────────────────── */
|
||||||
|
|
||||||
|
const NON_PICTURE_TYPES = new Set([
|
||||||
|
'static', 'gradient', 'color_cycle', 'effect', 'composite', 'mapped',
|
||||||
|
'audio', 'api_input', 'notification', 'daylight', 'candlelight', 'weather', 'processed', 'key_colors',
|
||||||
|
'math_wave',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/* ── Per-type card property renderers ─────────────────────────── */
|
||||||
|
|
||||||
|
const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
|
||||||
|
static: (source, { clockBadge, animBadge }) => {
|
||||||
|
const hexColor = rgbArrayToHex(bindableColor(source.color, [255,255,255]));
|
||||||
|
return `
|
||||||
|
<span class="stream-card-prop" title="${t('color_strip.static_color')}">
|
||||||
|
<span style="display:inline-block;width:14px;height:14px;background:${hexColor};border:1px solid #888;border-radius:2px;vertical-align:middle;margin-right:4px"></span>${hexColor.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
${animBadge}
|
||||||
|
${clockBadge}
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
color_cycle: (source, { clockBadge }) => {
|
||||||
|
const colors = source.colors || [];
|
||||||
|
const swatches = colors.slice(0, 8).map((c: any) =>
|
||||||
|
`<span style="display:inline-block;width:12px;height:12px;background:${rgbArrayToHex(c)};border:1px solid #888;border-radius:2px;margin-right:2px"></span>`
|
||||||
|
).join('');
|
||||||
|
return `
|
||||||
|
<span class="stream-card-prop">${swatches}</span>
|
||||||
|
${clockBadge}
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
gradient: (source, { clockBadge, animBadge }) => {
|
||||||
|
const stops = source.stops || [];
|
||||||
|
const sortedStops = [...stops].sort((a, b) => a.position - b.position);
|
||||||
|
let cssGradient = '';
|
||||||
|
if (sortedStops.length >= 2) {
|
||||||
|
const parts: string[] = [];
|
||||||
|
sortedStops.forEach(s => {
|
||||||
|
const pct = Math.round(s.position * 100);
|
||||||
|
parts.push(`${rgbArrayToHex(s.color)} ${pct}%`);
|
||||||
|
if (s.color_right) parts.push(`${rgbArrayToHex(s.color_right)} ${pct}%`);
|
||||||
|
});
|
||||||
|
cssGradient = `linear-gradient(to right, ${parts.join(', ')})`;
|
||||||
|
}
|
||||||
|
return `
|
||||||
|
${cssGradient ? `<span style="flex:1 1 100%;height:12px;background:${cssGradient};border-radius:3px;border:1px solid rgba(128,128,128,0.3)"></span>` : ''}
|
||||||
|
<span class="stream-card-prop">${ICON_PALETTE} ${stops.length} ${t('color_strip.gradient.stops_count')}</span>
|
||||||
|
${animBadge}
|
||||||
|
${clockBadge}
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
effect: (source, { clockBadge }) => {
|
||||||
|
const effectLabel = t('color_strip.effect.' + (source.effect_type || 'fire')) || source.effect_type || 'fire';
|
||||||
|
const paletteLabel = source.palette ? (t('color_strip.palette.' + source.palette) || source.palette) : '';
|
||||||
|
return `
|
||||||
|
<span class="stream-card-prop">${ICON_FPS} ${escapeHtml(effectLabel)}</span>
|
||||||
|
${paletteLabel ? `<span class="stream-card-prop" title="${t('color_strip.effect.palette')}">${ICON_PALETTE} ${escapeHtml(paletteLabel)}</span>` : ''}
|
||||||
|
${clockBadge}
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
composite: (source) => {
|
||||||
|
const layerCount = (source.layers || []).length;
|
||||||
|
const enabledCount = (source.layers || []).filter((l: any) => l.enabled !== false).length;
|
||||||
|
return `
|
||||||
|
<span class="stream-card-prop">${ICON_LINK} ${enabledCount}/${layerCount} ${t('color_strip.composite.layers_count')}</span>
|
||||||
|
${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">${ICON_LED} ${source.led_count}</span>` : ''}
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
mapped: (source) => {
|
||||||
|
const zoneCount = (source.zones || []).length;
|
||||||
|
return `
|
||||||
|
<span class="stream-card-prop">${ICON_MAP_PIN} ${zoneCount} ${t('color_strip.mapped.zones_count')}</span>
|
||||||
|
${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">${ICON_LED} ${source.led_count}</span>` : ''}
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
audio: (source, { audioSourceMap }) => {
|
||||||
|
const vizLabel = t('color_strip.audio.viz.' + (source.visualization_mode || 'spectrum')) || source.visualization_mode || 'spectrum';
|
||||||
|
const vizMode = source.visualization_mode || 'spectrum';
|
||||||
|
const showPalette = (vizMode === 'spectrum' || vizMode === 'beat_pulse' || vizMode === 'energy_gradient' || vizMode === 'spectrum_bands') && source.palette;
|
||||||
|
const audioPaletteLabel = showPalette ? (t('color_strip.palette.' + source.palette) || source.palette) : '';
|
||||||
|
return `
|
||||||
|
<span class="stream-card-prop">${ICON_MUSIC} ${escapeHtml(vizLabel)}</span>
|
||||||
|
${audioPaletteLabel ? `<span class="stream-card-prop" title="${t('color_strip.audio.palette')}">${ICON_PALETTE} ${escapeHtml(audioPaletteLabel)}</span>` : ''}
|
||||||
|
${source.audio_source_id ? (() => {
|
||||||
|
const as = audioSourceMap && audioSourceMap[source.audio_source_id];
|
||||||
|
const asName = as ? as.name : source.audio_source_id;
|
||||||
|
const asSection = as ? (as.source_type === 'processed' ? 'audio-processed' : 'audio-capture') : 'audio-capture';
|
||||||
|
const asTab = as ? (as.source_type === 'processed' ? 'audio_processed' : 'audio_capture') : 'audio_capture';
|
||||||
|
return `<span class="stream-card-prop${as ? ' stream-card-link' : ''}" title="${t('color_strip.audio.source')}"${as ? ` onclick="event.stopPropagation(); navigateToCard('streams','${asTab}','${asSection}','data-id','${source.audio_source_id}')"` : ''}>${ICON_AUDIO_LOOPBACK} ${escapeHtml(asName)}</span>`;
|
||||||
|
})() : ''}
|
||||||
|
${source.mirror ? `<span class="stream-card-prop">\u{1FA9E}</span>` : ''}
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
api_input: (source) => {
|
||||||
|
const fbColor = rgbArrayToHex(bindableColor(source.fallback_color, [0, 0, 0]));
|
||||||
|
const timeoutVal = bindableValue(source.timeout, 5.0).toFixed(1);
|
||||||
|
const interpLabel = t('color_strip.api_input.interpolation.' + (source.interpolation || 'linear')) || source.interpolation || 'linear';
|
||||||
|
return `
|
||||||
|
<span class="stream-card-prop" title="${t('color_strip.api_input.fallback_color')}">
|
||||||
|
<span style="display:inline-block;width:14px;height:14px;background:${fbColor};border:1px solid #888;border-radius:2px;vertical-align:middle;margin-right:4px"></span>${fbColor.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span class="stream-card-prop" title="${t('color_strip.api_input.timeout')}">${ICON_TIMER} ${timeoutVal}s</span>
|
||||||
|
<span class="stream-card-prop" title="${t('color_strip.api_input.interpolation')}">${escapeHtml(interpLabel)}</span>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
notification: (source) => {
|
||||||
|
const effectLabel = t('color_strip.notification.effect.' + (source.notification_effect || 'flash')) || source.notification_effect || 'flash';
|
||||||
|
const durationVal = source.duration_ms || 1500;
|
||||||
|
const defColorRgb = bindableColor(source.default_color as any, [255, 255, 255]);
|
||||||
|
const defColorHex = rgbArrayToHex(defColorRgb);
|
||||||
|
const appCount = source.app_colors ? Object.keys(source.app_colors).length : 0;
|
||||||
|
return `
|
||||||
|
<span class="stream-card-prop" title="${t('color_strip.notification.effect')}">${ICON_BELL} ${escapeHtml(effectLabel)}</span>
|
||||||
|
<span class="stream-card-prop" title="${t('color_strip.notification.duration')}">${ICON_TIMER} ${durationVal}ms</span>
|
||||||
|
<span class="stream-card-prop" title="${t('color_strip.notification.default_color')}">
|
||||||
|
<span style="display:inline-block;width:14px;height:14px;background:${defColorHex};border:1px solid #888;border-radius:2px;vertical-align:middle;margin-right:4px"></span>${defColorHex.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
${appCount > 0 ? `<span class="stream-card-prop">${ICON_PALETTE} ${appCount} ${t('color_strip.notification.app_count')}</span>` : ''}
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
daylight: (source, { clockBadge }) => {
|
||||||
|
const useRealTime = source.use_real_time;
|
||||||
|
const speedVal = bindableValue(source.speed, 1.0).toFixed(1);
|
||||||
|
return `
|
||||||
|
<span class="stream-card-prop">${useRealTime ? ICON_CLOCK + ' ' + t('color_strip.daylight.real_time') : ICON_FAST_FORWARD + ' ' + speedVal + 'x'}</span>
|
||||||
|
${clockBadge}
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
candlelight: (source, { clockBadge }) => {
|
||||||
|
const hexColor = rgbArrayToHex(bindableColor(source.color, [255, 147, 41]));
|
||||||
|
const numCandles = source.num_candles ?? 3;
|
||||||
|
return `
|
||||||
|
<span class="stream-card-prop" title="${t('color_strip.candlelight.color')}">
|
||||||
|
<span style="display:inline-block;width:14px;height:14px;background:${hexColor};border:1px solid #888;border-radius:2px;vertical-align:middle;margin-right:4px"></span>${hexColor.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span class="stream-card-prop">${numCandles} ${t('color_strip.candlelight.num_candles')}</span>
|
||||||
|
${clockBadge}
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
weather: (source, { clockBadge }) => {
|
||||||
|
const speedVal = bindableValue(source.speed, 1.0).toFixed(1);
|
||||||
|
const tempInfl = bindableValue(source.temperature_influence, 0.5).toFixed(1);
|
||||||
|
const ws = (_cachedWeatherSources || []).find((w: any) => w.id === source.weather_source_id);
|
||||||
|
const wsName = ws?.name || '\u2014';
|
||||||
|
const wsLink = ws
|
||||||
|
? ` stream-card-link" onclick="event.stopPropagation(); navigateToCard('integrations','weather','weather-sources','data-id','${source.weather_source_id}')`
|
||||||
|
: '';
|
||||||
|
return `
|
||||||
|
<span class="stream-card-prop${wsLink}" title="${t('color_strip.weather.source')}">${ICON_LINK_SOURCE} ${escapeHtml(wsName)}</span>
|
||||||
|
<span class="stream-card-prop">${ICON_FAST_FORWARD} ${speedVal}x</span>
|
||||||
|
<span class="stream-card-prop">${ICON_THERMOMETER} ${tempInfl}</span>
|
||||||
|
${clockBadge}
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
key_colors: (source, { pictureSourceMap }) => {
|
||||||
|
const rectCount = (source.rectangles || []).length;
|
||||||
|
const mode = source.interpolation_mode || 'average';
|
||||||
|
const ps = pictureSourceMap && source.picture_source_id ? pictureSourceMap[source.picture_source_id] : null;
|
||||||
|
const psName = ps?.name || '\u2014';
|
||||||
|
const psLink = ps
|
||||||
|
? ` stream-card-link" onclick="event.stopPropagation(); navigateToCard('streams','raw','raw-streams','data-stream-id','${source.picture_source_id}')`
|
||||||
|
: '';
|
||||||
|
return `
|
||||||
|
<span class="stream-card-prop${psLink}" title="${t('color_strip.key_colors.picture_source')}">${ICON_LINK_SOURCE} ${escapeHtml(psName)}</span>
|
||||||
|
<span class="stream-card-prop">${ICON_PALETTE} ${rectCount} region${rectCount !== 1 ? 's' : ''}</span>
|
||||||
|
<span class="stream-card-prop">${mode}</span>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
math_wave: (source, { clockBadge }) => {
|
||||||
|
const waveCount = (source.waves || []).length;
|
||||||
|
const speedVal = bindableValue(source.speed, 1.0).toFixed(1);
|
||||||
|
const gr = source.gradient_id ? _getGradients().find(g => g.id === source.gradient_id) : null;
|
||||||
|
const grName = gr?.name || '\u2014';
|
||||||
|
return `
|
||||||
|
<span class="stream-card-prop" title="${t('color_strip.math_wave.waves')}">${ICON_ACTIVITY} ${waveCount} wave${waveCount !== 1 ? 's' : ''}</span>
|
||||||
|
<span class="stream-card-prop" title="${t('color_strip.math_wave.gradient')}">${ICON_PALETTE} ${escapeHtml(grName)}</span>
|
||||||
|
<span class="stream-card-prop">${ICON_FAST_FORWARD} ${speedVal}x</span>
|
||||||
|
${clockBadge}
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
processed: (source) => {
|
||||||
|
const inputSrc = ((colorStripSourcesCache.data || []) as any[]).find(s => s.id === source.input_source_id);
|
||||||
|
const inputName = inputSrc?.name || source.input_source_id || '\u2014';
|
||||||
|
const tplName = source.processing_template_id
|
||||||
|
? (_cachedCSPTemplates.find(t => t.id === source.processing_template_id)?.name || source.processing_template_id)
|
||||||
|
: '\u2014';
|
||||||
|
return `
|
||||||
|
<span class="stream-card-prop" title="${t('color_strip.processed.input')}">${ICON_LINK_SOURCE} ${escapeHtml(inputName)}</span>
|
||||||
|
<span class="stream-card-prop" title="${t('color_strip.processed.template')}">${ICON_SPARKLES} ${escapeHtml(tplName)}</span>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
picture_advanced: (source, { pictureSourceMap }) => {
|
||||||
|
const cal = source.calibration ?? {} as Partial<import('../../types.ts').Calibration>;
|
||||||
|
const lines = cal.lines || [];
|
||||||
|
const totalLeds = lines.reduce((s: any, l: any) => s + (l.led_count || 0), 0);
|
||||||
|
const ledCount = (source.led_count > 0) ? source.led_count : totalLeds;
|
||||||
|
const psIds: any[] = [...new Set(lines.map((l: any) => l.picture_source_id).filter(Boolean))];
|
||||||
|
const psNames = psIds.map((id: any) => {
|
||||||
|
const ps = pictureSourceMap && pictureSourceMap[id];
|
||||||
|
return ps ? ps.name : id;
|
||||||
|
});
|
||||||
|
return `
|
||||||
|
<span class="stream-card-prop" title="${t('calibration.advanced.lines_title')}">${ICON_MAP_PIN} ${lines.length} ${t('calibration.advanced.lines_title').toLowerCase()}</span>
|
||||||
|
${ledCount ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">${ICON_LED} ${ledCount}</span>` : ''}
|
||||||
|
${psNames.length ? `<span class="stream-card-prop stream-card-prop-full" title="${t('color_strip.picture_source')}">${ICON_LINK_SOURCE} ${escapeHtml(psNames.join(', '))}</span>` : ''}
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── Fallback picture renderer ────────────────────────────────── */
|
||||||
|
|
||||||
|
function _renderPictureCardProps(source: ColorStripSource, pictureSourceMap: Record<string, any>): string {
|
||||||
|
const ps = pictureSourceMap && pictureSourceMap[source.picture_source_id!];
|
||||||
|
const srcName = ps ? ps.name : source.picture_source_id || '\u2014';
|
||||||
|
const cal = source.calibration ?? {} as Partial<import('../../types.ts').Calibration>;
|
||||||
|
const calLeds = (cal.leds_top || 0) + (cal.leds_right || 0) + (cal.leds_bottom || 0) + (cal.leds_left || 0);
|
||||||
|
const ledCount = (source.led_count > 0) ? source.led_count : calLeds;
|
||||||
|
let psSubTab = 'raw', psSection = 'raw-streams';
|
||||||
|
if (ps) {
|
||||||
|
if (ps.stream_type === 'static_image') { psSubTab = 'static_image'; psSection = 'static-streams'; }
|
||||||
|
else if (ps.stream_type === 'processed') { psSubTab = 'processed'; psSection = 'proc-streams'; }
|
||||||
|
}
|
||||||
|
return `
|
||||||
|
<span class="stream-card-prop stream-card-prop-full${ps ? ' stream-card-link' : ''}" title="${t('color_strip.picture_source')}"${ps ? ` onclick="event.stopPropagation(); navigateToCard('streams','${psSubTab}','${psSection}','data-stream-id','${source.picture_source_id}')"` : ''}>${ICON_LINK_SOURCE} ${escapeHtml(srcName)}</span>
|
||||||
|
${ledCount ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">${ICON_LED} ${ledCount}</span>` : ''}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Main card builder ────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export function createColorStripCard(source: ColorStripSource, pictureSourceMap: Record<string, any>, audioSourceMap: Record<string, any>) {
|
||||||
|
// Clock crosslink badge
|
||||||
|
const clockObj = source.clock_id ? _cachedSyncClocks.find(c => c.id === source.clock_id) : null;
|
||||||
|
const clockBadge = clockObj
|
||||||
|
? `<span class="stream-card-prop stream-card-link" title="${t('color_strip.clock')}" onclick="event.stopPropagation(); navigateToCard('streams','sync','sync-clocks','data-id','${source.clock_id}')">${ICON_CLOCK} ${escapeHtml(clockObj.name)}</span>`
|
||||||
|
: source.clock_id ? `<span class="stream-card-prop">${ICON_CLOCK} ${source.clock_id}</span>` : '';
|
||||||
|
|
||||||
|
const isAnimatable = source.source_type === 'static' || source.source_type === 'gradient';
|
||||||
|
const anim = isAnimatable && source.animation && source.animation.enabled ? source.animation : null;
|
||||||
|
const animBadge = anim
|
||||||
|
? `<span class="stream-card-prop" title="${t('color_strip.animation')}">${ICON_SPARKLES} ${t('color_strip.animation.type.' + anim.type) || anim.type}</span>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const renderer = CSS_CARD_RENDERERS[source.source_type];
|
||||||
|
const propsHtml = renderer
|
||||||
|
? renderer(source, { clockBadge, animBadge, audioSourceMap, pictureSourceMap })
|
||||||
|
: _renderPictureCardProps(source, pictureSourceMap);
|
||||||
|
|
||||||
|
const icon = getColorStripIcon(source.source_type);
|
||||||
|
const isNotification = source.source_type === 'notification';
|
||||||
|
const isPictureKind = !NON_PICTURE_TYPES.has(source.source_type);
|
||||||
|
const calibrationBtn = isPictureKind
|
||||||
|
? `<button class="btn btn-icon btn-secondary" onclick="${source.source_type === 'picture_advanced' ? `showAdvancedCalibration('${source.id}')` : `showCSSCalibration('${source.id}')`}" title="${t('calibration.title')}">${ICON_CALIBRATION}</button>`
|
||||||
|
: '';
|
||||||
|
const overlayBtn = isPictureKind
|
||||||
|
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); toggleCSSOverlay('${source.id}')" title="${t('overlay.toggle')}">${ICON_OVERLAY}</button>`
|
||||||
|
: '';
|
||||||
|
const testNotifyBtn = isNotification
|
||||||
|
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); testNotification('${source.id}')" title="${t('color_strip.notification.test')}">${ICON_BELL}</button>`
|
||||||
|
: '';
|
||||||
|
const notifHistoryBtn = isNotification
|
||||||
|
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); showNotificationHistory()" title="${t('color_strip.notification.history.title')}">${ICON_AUTOMATION}</button>`
|
||||||
|
: '';
|
||||||
|
const isKeyColors = source.source_type === 'key_colors';
|
||||||
|
const regionsBtn = isKeyColors
|
||||||
|
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); configureKCRegions('${source.id}')" title="${t('color_strip.key_colors.configure_regions')}">${ICON_PATTERN_TEMPLATE}</button>`
|
||||||
|
: '';
|
||||||
|
const testPreviewBtn = `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); testColorStrip('${source.id}')" title="${t('color_strip.test.title')}">${ICON_TEST}</button>`;
|
||||||
|
|
||||||
|
return wrapCard({
|
||||||
|
dataAttr: 'data-css-id',
|
||||||
|
id: source.id,
|
||||||
|
removeOnclick: `deleteColorStrip('${source.id}')`,
|
||||||
|
removeTitle: t('common.delete'),
|
||||||
|
content: `
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-title" title="${escapeHtml(source.name)}">
|
||||||
|
${icon} <span class="card-title-text">${escapeHtml(source.name)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stream-card-props">
|
||||||
|
${propsHtml}
|
||||||
|
</div>
|
||||||
|
${renderTagChips(source.tags)}`,
|
||||||
|
actions: `
|
||||||
|
<button class="btn btn-icon btn-secondary" onclick="cloneColorStrip('${source.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
|
||||||
|
<button class="btn btn-icon btn-secondary" onclick="showCSSEditor('${source.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>
|
||||||
|
${calibrationBtn}${overlayBtn}${regionsBtn}${testNotifyBtn}${notifHistoryBtn}${testPreviewBtn}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* Color Strip Sources — Color Cycle helpers.
|
||||||
|
* Extracted from color-strips.ts to reduce file size.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ICON_TRASH } from '../../core/icons.ts';
|
||||||
|
import { rgbArrayToHex, hexToRgbArray } from '../css-gradient-editor.ts';
|
||||||
|
|
||||||
|
/* ── State ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const _DEFAULT_CYCLE_COLORS = ['#ff0000', '#ffff00', '#00ff00', '#00ffff', '#0000ff', '#ff00ff'];
|
||||||
|
let _colorCycleColors = [..._DEFAULT_CYCLE_COLORS];
|
||||||
|
|
||||||
|
/* ── DOM sync ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function _syncColorCycleFromDom() {
|
||||||
|
const inputs = document.querySelectorAll<HTMLInputElement>('#color-cycle-colors-list input[type=color]');
|
||||||
|
if (inputs.length > 0) {
|
||||||
|
_colorCycleColors = Array.from(inputs).map(el => el.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Rendering ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function _colorCycleRenderList() {
|
||||||
|
const list = document.getElementById('color-cycle-colors-list') as HTMLElement | null;
|
||||||
|
if (!list) return;
|
||||||
|
const canRemove = _colorCycleColors.length > 2;
|
||||||
|
list.innerHTML = _colorCycleColors.map((hex, i) => `
|
||||||
|
<div class="color-cycle-item">
|
||||||
|
<input type="color" value="${hex}">
|
||||||
|
${canRemove
|
||||||
|
? `<button type="button" class="btn btn-secondary color-cycle-remove-btn"
|
||||||
|
onclick="colorCycleRemoveColor(${i})">${ICON_TRASH}</button>`
|
||||||
|
: `<div style="height:14px"></div>`}
|
||||||
|
</div>
|
||||||
|
`).join('') + `<div class="color-cycle-item"><button type="button" class="btn btn-secondary color-cycle-add-btn" onclick="colorCycleAddColor()">+</button></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Public actions ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export function colorCycleAddColor() {
|
||||||
|
_syncColorCycleFromDom();
|
||||||
|
_colorCycleColors.push('#ffffff');
|
||||||
|
_colorCycleRenderList();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function colorCycleRemoveColor(i: number) {
|
||||||
|
_syncColorCycleFromDom();
|
||||||
|
if (_colorCycleColors.length <= 2) return;
|
||||||
|
_colorCycleColors.splice(i, 1);
|
||||||
|
_colorCycleRenderList();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function colorCycleGetColors() {
|
||||||
|
const inputs = document.querySelectorAll<HTMLInputElement>('#color-cycle-colors-list input[type=color]');
|
||||||
|
return Array.from(inputs).map(el => hexToRgbArray(el.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Load / Reset ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export function loadColorCycleState(css: any) {
|
||||||
|
const raw = css && css.colors;
|
||||||
|
_colorCycleColors = (raw && raw.length >= 2)
|
||||||
|
? raw.map((c: any) => rgbArrayToHex(c))
|
||||||
|
: [..._DEFAULT_CYCLE_COLORS];
|
||||||
|
_colorCycleRenderList();
|
||||||
|
}
|
||||||
+8
-8
@@ -3,17 +3,17 @@
|
|||||||
* Extracted from color-strips.ts to reduce file size.
|
* Extracted from color-strips.ts to reduce file size.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { escapeHtml } from '../core/api.ts';
|
import { escapeHtml } from '../../core/api.ts';
|
||||||
import { _cachedValueSources, _cachedCSPTemplates } from '../core/state.ts';
|
import { _cachedValueSources, _cachedCSPTemplates } from '../../core/state.ts';
|
||||||
import { t } from '../core/i18n.ts';
|
import { t } from '../../core/i18n.ts';
|
||||||
import {
|
import {
|
||||||
getColorStripIcon, getValueSourceIcon,
|
getColorStripIcon, getValueSourceIcon,
|
||||||
ICON_SPARKLES, ICON_TRASH,
|
ICON_SPARKLES, ICON_TRASH,
|
||||||
} from '../core/icons.ts';
|
} from '../../core/icons.ts';
|
||||||
import * as P from '../core/icon-paths.ts';
|
import * as P from '../../core/icon-paths.ts';
|
||||||
import { IconSelect } from '../core/icon-select.ts';
|
import { IconSelect } from '../../core/icon-select.ts';
|
||||||
import { EntitySelect } from '../core/entity-palette.ts';
|
import { EntitySelect } from '../../core/entity-palette.ts';
|
||||||
import { BindableScalarWidget } from '../core/bindable-scalar.ts';
|
import { BindableScalarWidget } from '../../core/bindable-scalar.ts';
|
||||||
|
|
||||||
const _icon = (d: any) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
const _icon = (d: any) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
||||||
|
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
/**
|
||||||
|
* Color Strip Sources — Game Event helpers.
|
||||||
|
* Extracted from color-strips.ts to reduce file size.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { escapeHtml } from '../../core/api.ts';
|
||||||
|
import { _cachedGameIntegrations, _cachedGameAdapters, _cachedValueSources } from '../../core/state.ts';
|
||||||
|
import { t } from '../../core/i18n.ts';
|
||||||
|
import { ICON_TRASH, ICON_GAMEPAD } from '../../core/icons.ts';
|
||||||
|
import * as P from '../../core/icon-paths.ts';
|
||||||
|
import { IconSelect, type IconSelectItem } from '../../core/icon-select.ts';
|
||||||
|
import { EntitySelect } from '../../core/entity-palette.ts';
|
||||||
|
import { BindableColorWidget } from '../../core/bindable-color.ts';
|
||||||
|
|
||||||
|
/* ── State ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
let _gameEventIdleColorWidget: BindableColorWidget | null = null;
|
||||||
|
let _cssGameIntegrationEntitySelect: EntitySelect | null = null;
|
||||||
|
let _cssGameMappings: any[] = [];
|
||||||
|
let _cssGameMappingIconSelects: IconSelect[] = [];
|
||||||
|
let _cssGamePresetIconSelect: IconSelect | null = null;
|
||||||
|
|
||||||
|
/* ── Destroy helpers (called from modal onForceClose) ─────────── */
|
||||||
|
|
||||||
|
export function destroyGameEventWidgets() {
|
||||||
|
if (_gameEventIdleColorWidget) { _gameEventIdleColorWidget.destroy(); _gameEventIdleColorWidget = null; }
|
||||||
|
if (_cssGameIntegrationEntitySelect) { _cssGameIntegrationEntitySelect.destroy(); _cssGameIntegrationEntitySelect = null; }
|
||||||
|
destroyCSSGameMappingIconSelects();
|
||||||
|
if (_cssGamePresetIconSelect) { _cssGamePresetIconSelect.destroy(); _cssGamePresetIconSelect = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroyCSSGameMappingIconSelects() {
|
||||||
|
_cssGameMappingIconSelects.forEach(is => is.destroy());
|
||||||
|
_cssGameMappingIconSelects = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Color/hex helpers ────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function _hexToRgbCSS(hex: string): number[] {
|
||||||
|
const m = hex.replace('#', '').match(/.{2}/g);
|
||||||
|
if (!m) return [255, 0, 0];
|
||||||
|
return m.map(c => parseInt(c, 16));
|
||||||
|
}
|
||||||
|
|
||||||
|
function _rgbToHexCSS(rgb: number[]): string {
|
||||||
|
return '#' + rgb.map(c => c.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Available event types ────────────────────────────────────── */
|
||||||
|
|
||||||
|
function _getCSSGameAvailableEventTypes(): string[] {
|
||||||
|
const giId = (document.getElementById('css-editor-game-integration') as HTMLSelectElement)?.value;
|
||||||
|
if (giId) {
|
||||||
|
const gi = (_cachedGameIntegrations || []).find(g => g.id === giId);
|
||||||
|
if (gi) {
|
||||||
|
const adapter = (_cachedGameAdapters || []).find(a => a.adapter_type === gi.adapter_type);
|
||||||
|
if (adapter && adapter.supported_events.length > 0) return adapter.supported_events;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ['kill', 'death', 'health', 'armor', 'round_start', 'round_end', 'bomb_planted', 'bomb_defused', 'assist', 'headshot'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Constants ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const _CSS_GE_EFFECT_TYPES: IconSelectItem[] = [
|
||||||
|
{ value: 'flash', label: 'Flash', icon: `<svg class="icon" viewBox="0 0 24 24">${P.zap}</svg>` },
|
||||||
|
{ value: 'pulse', label: 'Pulse', icon: `<svg class="icon" viewBox="0 0 24 24">${P.activity}</svg>` },
|
||||||
|
{ value: 'sweep', label: 'Sweep', icon: `<svg class="icon" viewBox="0 0 24 24">${P.fastForward}</svg>` },
|
||||||
|
{ value: 'color_shift', label: 'Color Shift', icon: `<svg class="icon" viewBox="0 0 24 24">${P.rainbow}</svg>` },
|
||||||
|
{ value: 'breathing', label: 'Breathing', icon: `<svg class="icon" viewBox="0 0 24 24">${P.heart}</svg>` },
|
||||||
|
];
|
||||||
|
|
||||||
|
const _CSS_GE_EVENT_ICONS: Record<string, string> = {
|
||||||
|
kill: P.crosshair, death: P.xIcon, health: P.heart, armor: P.shield,
|
||||||
|
round_start: P.play, round_end: P.square, bomb_planted: P.flame, bomb_defused: P.circleCheck,
|
||||||
|
assist: P.swords, headshot: P.target, damage: P.zap, gold: P.star,
|
||||||
|
level_up: P.trendingUp, respawn: P.refreshCw, item_pickup: P.packageIcon,
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── Build helpers ────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function _buildCSSGameEventTypeItems(): IconSelectItem[] {
|
||||||
|
return _getCSSGameAvailableEventTypes().map(et => ({
|
||||||
|
value: et,
|
||||||
|
label: et,
|
||||||
|
icon: `<svg class="icon" viewBox="0 0 24 24">${_CSS_GE_EVENT_ICONS[et] || P.circleDot}</svg>`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Rendering ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function _renderCSSGameMappingRow(mapping: any, index: number): string {
|
||||||
|
const eventTypes = _getCSSGameAvailableEventTypes();
|
||||||
|
const eventOptions = eventTypes.map(et =>
|
||||||
|
`<option value="${et}"${et === mapping.event_type ? ' selected' : ''}>${et}</option>`
|
||||||
|
).join('');
|
||||||
|
const effectOptions = _CSS_GE_EFFECT_TYPES.map(ef =>
|
||||||
|
`<option value="${ef.value}"${ef.value === mapping.effect_type ? ' selected' : ''}>${ef.label}</option>`
|
||||||
|
).join('');
|
||||||
|
const effectLabel = _CSS_GE_EFFECT_TYPES.find(ef => ef.value === mapping.effect_type)?.label || mapping.effect_type;
|
||||||
|
const hexColor = _rgbToHexCSS(mapping.color || [255, 0, 0]);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="gi-mapping-row" data-mapping-index="${index}">
|
||||||
|
<div class="gi-mapping-header">
|
||||||
|
<span class="gi-mapping-expand-btn">▶</span>
|
||||||
|
<span class="gi-mapping-summary">
|
||||||
|
<span class="gi-mapping-summary-event">${escapeHtml(mapping.event_type)}</span>
|
||||||
|
<span class="gi-mapping-summary-effect">${escapeHtml(effectLabel)}</span>
|
||||||
|
<span class="gi-mapping-summary-color" style="background:${hexColor}"></span>
|
||||||
|
</span>
|
||||||
|
<button type="button" class="btn-remove-rule" onclick="event.stopPropagation(); removeCSSGameMapping(${index})" title="${t('common.delete')}">${ICON_TRASH}</button>
|
||||||
|
</div>
|
||||||
|
<div class="gi-mapping-body-wrapper">
|
||||||
|
<div class="gi-mapping-body">
|
||||||
|
<div class="gi-mapping-field-row">
|
||||||
|
<label>${t('game_integration.mapping.event_type')}</label>
|
||||||
|
<select data-field="event_type">${eventOptions}</select>
|
||||||
|
</div>
|
||||||
|
<div class="gi-mapping-field-row">
|
||||||
|
<label>${t('game_integration.mapping.effect_type')}</label>
|
||||||
|
<select data-field="effect_type">${effectOptions}</select>
|
||||||
|
</div>
|
||||||
|
<div class="gi-mapping-field-row">
|
||||||
|
<label>${t('game_integration.mapping.color')}</label>
|
||||||
|
<input type="color" data-field="color" value="${hexColor}">
|
||||||
|
</div>
|
||||||
|
<div class="gi-mapping-field-row">
|
||||||
|
<label>${t('game_integration.mapping.duration')}</label>
|
||||||
|
<input type="number" data-field="duration_ms" value="${mapping.duration_ms || 500}" min="50" max="10000" step="50">
|
||||||
|
</div>
|
||||||
|
<div class="gi-mapping-field-row">
|
||||||
|
<label>${t('game_integration.mapping.intensity')}</label>
|
||||||
|
<input type="range" data-field="intensity" value="${mapping.intensity ?? 1.0}" min="0" max="1" step="0.05"
|
||||||
|
oninput="this.title = this.value">
|
||||||
|
</div>
|
||||||
|
<div class="gi-mapping-field-row">
|
||||||
|
<label>${t('game_integration.mapping.priority')}</label>
|
||||||
|
<input type="number" data-field="priority" value="${mapping.priority || 5}" min="1" max="10">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _wireCSSGameMappingRows(container: HTMLElement) {
|
||||||
|
container.querySelectorAll('.gi-mapping-header').forEach(header => {
|
||||||
|
const item = header.closest('.gi-mapping-row') as HTMLElement;
|
||||||
|
header.addEventListener('click', (e: Event) => {
|
||||||
|
if ((e.target as HTMLElement).closest('.btn-remove-rule')) return;
|
||||||
|
item.classList.toggle('gi-mapping-expanded');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
container.querySelectorAll('.gi-mapping-row').forEach(row => {
|
||||||
|
const eventSel = row.querySelector('[data-field="event_type"]') as HTMLSelectElement | null;
|
||||||
|
const effectSel = row.querySelector('[data-field="effect_type"]') as HTMLSelectElement | null;
|
||||||
|
const colorInput = row.querySelector('input[data-field="color"]') as HTMLInputElement | null;
|
||||||
|
const summaryEvent = row.querySelector('.gi-mapping-summary-event') as HTMLElement | null;
|
||||||
|
const summaryEffect = row.querySelector('.gi-mapping-summary-effect') as HTMLElement | null;
|
||||||
|
const summaryColor = row.querySelector('.gi-mapping-summary-color') as HTMLElement | null;
|
||||||
|
|
||||||
|
if (eventSel) {
|
||||||
|
const is = new IconSelect({ target: eventSel, items: _buildCSSGameEventTypeItems(), columns: 4 });
|
||||||
|
_cssGameMappingIconSelects.push(is);
|
||||||
|
if (summaryEvent) {
|
||||||
|
eventSel.addEventListener('change', () => { summaryEvent.textContent = eventSel.value; });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (effectSel) {
|
||||||
|
const is = new IconSelect({ target: effectSel, items: _CSS_GE_EFFECT_TYPES, columns: 3 });
|
||||||
|
_cssGameMappingIconSelects.push(is);
|
||||||
|
if (summaryEffect) {
|
||||||
|
effectSel.addEventListener('change', () => {
|
||||||
|
const label = _CSS_GE_EFFECT_TYPES.find(ef => ef.value === effectSel.value)?.label || effectSel.value;
|
||||||
|
summaryEffect.textContent = label;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (colorInput && summaryColor) {
|
||||||
|
colorInput.addEventListener('input', () => { summaryColor.style.background = colorInput.value; });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderCSSGameMappings(mappings: any[]) {
|
||||||
|
_cssGameMappings = [...mappings];
|
||||||
|
destroyCSSGameMappingIconSelects();
|
||||||
|
const container = document.getElementById('css-editor-ge-mappings-list');
|
||||||
|
if (!container) return;
|
||||||
|
container.innerHTML = mappings.map((m, i) => _renderCSSGameMappingRow(m, i)).join('');
|
||||||
|
_wireCSSGameMappingRows(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectCSSGameMappings(): any[] {
|
||||||
|
const rows = document.querySelectorAll('#css-editor-ge-mappings-list .gi-mapping-row');
|
||||||
|
return Array.from(rows).map(row => {
|
||||||
|
const eventType = (row.querySelector('[data-field="event_type"]') as HTMLSelectElement)?.value || 'kill';
|
||||||
|
const effectType = (row.querySelector('[data-field="effect_type"]') as HTMLSelectElement)?.value || 'flash';
|
||||||
|
const colorInput = (row.querySelector('input[data-field="color"]') as HTMLInputElement)?.value || '#ff0000';
|
||||||
|
const duration = parseFloat((row.querySelector('[data-field="duration_ms"]') as HTMLInputElement)?.value) || 500;
|
||||||
|
const intensity = parseFloat((row.querySelector('[data-field="intensity"]') as HTMLInputElement)?.value) || 1.0;
|
||||||
|
const priority = parseInt((row.querySelector('[data-field="priority"]') as HTMLInputElement)?.value) || 5;
|
||||||
|
return { event_type: eventType, effect_type: effectType, color: _hexToRgbCSS(colorInput), duration_ms: duration, intensity, priority };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Public actions ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export function addCSSGameMapping() {
|
||||||
|
const collected = collectCSSGameMappings();
|
||||||
|
collected.push({
|
||||||
|
event_type: _getCSSGameAvailableEventTypes()[0] || 'kill',
|
||||||
|
effect_type: 'flash',
|
||||||
|
color: [255, 0, 0],
|
||||||
|
duration_ms: 500,
|
||||||
|
intensity: 1.0,
|
||||||
|
priority: 5,
|
||||||
|
});
|
||||||
|
_renderCSSGameMappings(collected);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeCSSGameMapping(index: number) {
|
||||||
|
const collected = collectCSSGameMappings();
|
||||||
|
collected.splice(index, 1);
|
||||||
|
_renderCSSGameMappings(collected);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onCSSGameMappingPresetChange() {
|
||||||
|
const sel = document.getElementById('css-editor-ge-mapping-preset') as HTMLSelectElement;
|
||||||
|
if (!sel.value) return;
|
||||||
|
const presets: Record<string, any[]> = {
|
||||||
|
fps_combat: [
|
||||||
|
{ event_type: 'kill', effect_type: 'flash', color: [0, 255, 0], duration_ms: 400, intensity: 1.0, priority: 8 },
|
||||||
|
{ event_type: 'death', effect_type: 'pulse', color: [255, 0, 0], duration_ms: 1500, intensity: 1.0, priority: 10 },
|
||||||
|
{ event_type: 'headshot', effect_type: 'flash', color: [255, 215, 0], duration_ms: 300, intensity: 1.0, priority: 9 },
|
||||||
|
{ event_type: 'health', effect_type: 'breathing', color: [255, 50, 50], duration_ms: 2000, intensity: 0.6, priority: 3 },
|
||||||
|
{ event_type: 'round_start', effect_type: 'sweep', color: [0, 100, 255], duration_ms: 800, intensity: 0.8, priority: 5 },
|
||||||
|
],
|
||||||
|
moba_health: [
|
||||||
|
{ event_type: 'health', effect_type: 'color_shift', color: [0, 255, 0], duration_ms: 1000, intensity: 0.7, priority: 4 },
|
||||||
|
{ event_type: 'kill', effect_type: 'flash', color: [255, 215, 0], duration_ms: 500, intensity: 1.0, priority: 8 },
|
||||||
|
{ event_type: 'death', effect_type: 'pulse', color: [255, 0, 0], duration_ms: 2000, intensity: 1.0, priority: 10 },
|
||||||
|
{ event_type: 'assist', effect_type: 'flash', color: [100, 200, 255], duration_ms: 300, intensity: 0.8, priority: 6 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const preset = presets[sel.value];
|
||||||
|
if (preset) _renderCSSGameMappings(preset);
|
||||||
|
sel.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initCSSGamePresetIconSelect() {
|
||||||
|
const sel = document.getElementById('css-editor-ge-mapping-preset') as HTMLSelectElement | null;
|
||||||
|
if (!sel) return;
|
||||||
|
if (_cssGamePresetIconSelect) { _cssGamePresetIconSelect.destroy(); _cssGamePresetIconSelect = null; }
|
||||||
|
const items: IconSelectItem[] = [
|
||||||
|
{ value: '', label: t('game_integration.mapping.select_preset'), icon: '' },
|
||||||
|
{ value: 'fps_combat', label: t('game_integration.preset.fps_combat'), icon: `<svg class="icon" viewBox="0 0 24 24">${P.crosshair}</svg>` },
|
||||||
|
{ value: 'moba_health', label: t('game_integration.preset.moba_health'), icon: `<svg class="icon" viewBox="0 0 24 24">${P.heart}</svg>` },
|
||||||
|
];
|
||||||
|
_cssGamePresetIconSelect = new IconSelect({ target: sel, items, columns: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Widget ensure helpers ────────────────────────────────────── */
|
||||||
|
|
||||||
|
export function ensureGameEventIdleColorWidget(): BindableColorWidget {
|
||||||
|
if (!_gameEventIdleColorWidget) {
|
||||||
|
_gameEventIdleColorWidget = new BindableColorWidget({
|
||||||
|
container: document.getElementById('css-editor-game-event-idle-color-container')!,
|
||||||
|
default: [0, 0, 0],
|
||||||
|
valueSources: () => _cachedValueSources,
|
||||||
|
idPrefix: 'css-editor-ge-idle-color',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _gameEventIdleColorWidget;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function populateGameIntegrationDropdownCSS(selectedId: string = '') {
|
||||||
|
const sel = document.getElementById('css-editor-game-integration') as HTMLSelectElement;
|
||||||
|
const integrations = _cachedGameIntegrations || [];
|
||||||
|
const prev = selectedId || sel.value;
|
||||||
|
sel.innerHTML = `<option value="">${t('common.none_no_input')}</option>` +
|
||||||
|
integrations.map(gi => `<option value="${gi.id}"${gi.id === prev ? ' selected' : ''}>${escapeHtml(gi.name)}</option>`).join('');
|
||||||
|
sel.value = prev || '';
|
||||||
|
|
||||||
|
if (_cssGameIntegrationEntitySelect) _cssGameIntegrationEntitySelect.destroy();
|
||||||
|
_cssGameIntegrationEntitySelect = new EntitySelect({
|
||||||
|
target: sel,
|
||||||
|
getItems: () => integrations.map(gi => ({
|
||||||
|
value: gi.id,
|
||||||
|
label: gi.name,
|
||||||
|
icon: ICON_GAMEPAD,
|
||||||
|
desc: gi.adapter_type,
|
||||||
|
})),
|
||||||
|
allowNone: true,
|
||||||
|
noneLabel: t('common.none_no_input'),
|
||||||
|
placeholder: t('palette.search'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderCSSGameMappings(mappings: any[]) {
|
||||||
|
_renderCSSGameMappings(mappings);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get raw mappings array (for snapshot). */
|
||||||
|
export function getCSSGameMappings(): any[] {
|
||||||
|
return _cssGameMappings;
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
/**
|
||||||
|
* Color Strip Sources — Gradient entity management (modal + CRUD).
|
||||||
|
* Extracted from color-strips.ts to reduce file size.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fetchWithAuth } from '../../core/api.ts';
|
||||||
|
import { gradientsCache, GradientEntity } from '../../core/state.ts';
|
||||||
|
import { t } from '../../core/i18n.ts';
|
||||||
|
import { showToast, showConfirm } from '../../core/ui.ts';
|
||||||
|
import { Modal } from '../../core/modal.ts';
|
||||||
|
import { ICON_PALETTE, ICON_TRASH } from '../../core/icons.ts';
|
||||||
|
import { TagInput, renderTagChips } from '../../core/tag-input.ts';
|
||||||
|
import { escapeHtml } from '../../core/api.ts';
|
||||||
|
import {
|
||||||
|
gradientInit, gradientRenderAll, getGradientStops, gradientSetIdPrefix,
|
||||||
|
} from '../css-gradient-editor.ts';
|
||||||
|
|
||||||
|
/* ── Helpers ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function _getGradients(): GradientEntity[] {
|
||||||
|
return gradientsCache.data || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function _findGradient(id: string): GradientEntity | undefined {
|
||||||
|
return _getGradients().find(g => g.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _gradientEntityStripHTML(stops: Array<{ position: number; color: number[] }>, w = 80, h = 16) {
|
||||||
|
const css = stops.map(s => `rgb(${s.color.join(',')}) ${(s.position * 100).toFixed(0)}%`).join(', ');
|
||||||
|
return `<span style="display:inline-block;width:${w}px;height:${h}px;border-radius:3px;background:linear-gradient(to right,${css});flex-shrink:0"></span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Gradient entity items builder (shared with palettes) ────── */
|
||||||
|
|
||||||
|
export function buildGradientEntityItems() {
|
||||||
|
const gradients = _getGradients();
|
||||||
|
return gradients.map(g => ({
|
||||||
|
value: g.id, icon: _gradientEntityStripHTML(g.stops), label: g.name,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sync <option> elements in a <select> from items array. */
|
||||||
|
export function syncSelectOptions(sel: HTMLSelectElement, items: Array<{ value: string; label: string }>) {
|
||||||
|
sel.innerHTML = '';
|
||||||
|
for (const item of items) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = item.value;
|
||||||
|
opt.textContent = item.label;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Gradient preset picker refresh ──────────────────────────── */
|
||||||
|
|
||||||
|
export { _getGradients as getGradients, _findGradient as findGradient, _gradientEntityStripHTML as gradientEntityStripHTML };
|
||||||
|
|
||||||
|
/* ── Custom preset list below gradient editor ─────────────────── */
|
||||||
|
|
||||||
|
export function renderCustomPresetList() {
|
||||||
|
const container = document.getElementById('css-editor-custom-presets-list') as HTMLElement | null;
|
||||||
|
if (!container) return;
|
||||||
|
const userGradients = _getGradients().filter(g => !g.is_builtin);
|
||||||
|
if (userGradients.length === 0) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = userGradients.map(g => {
|
||||||
|
const strip = _gradientEntityStripHTML(g.stops, 60, 14);
|
||||||
|
const safeName = escapeHtml(g.name);
|
||||||
|
return `<div class="custom-preset-row">
|
||||||
|
${strip}
|
||||||
|
<span class="custom-preset-name">${safeName}</span>
|
||||||
|
<button type="button" class="btn btn-icon btn-sm btn-secondary"
|
||||||
|
onclick="onGradientPresetChange('${g.id}')"
|
||||||
|
title="${t('color_strip.gradient.preset.apply')}">✓</button>
|
||||||
|
<button type="button" class="btn btn-icon btn-sm btn-danger"
|
||||||
|
onclick="deleteAndRefreshGradientPreset('${g.id}')"
|
||||||
|
title="${t('common.delete')}">${ICON_TRASH}</button>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Inline preset actions (from CSS editor) ──────────────────── */
|
||||||
|
|
||||||
|
export function onGradientPresetChange(value: any) {
|
||||||
|
if (!value) return;
|
||||||
|
const g = _findGradient(value);
|
||||||
|
if (g) {
|
||||||
|
gradientInit(g.stops);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function promptAndSaveGradientPreset() {
|
||||||
|
const name = window.prompt(t('color_strip.gradient.preset.save_prompt'), '');
|
||||||
|
if (!name || !name.trim()) return;
|
||||||
|
const stops = getGradientStops().map(s => ({
|
||||||
|
position: s.position,
|
||||||
|
color: s.color,
|
||||||
|
}));
|
||||||
|
try {
|
||||||
|
await fetchWithAuth('/gradients', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: name.trim(), stops }),
|
||||||
|
});
|
||||||
|
await gradientsCache.fetch({ force: true });
|
||||||
|
showToast(t('color_strip.gradient.preset.saved'), 'success');
|
||||||
|
} catch (e: any) {
|
||||||
|
showToast(e.message || 'Failed to save gradient', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAndRefreshGradientPreset(gradientId: any) {
|
||||||
|
try {
|
||||||
|
await fetchWithAuth(`/gradients/${gradientId}`, { method: 'DELETE' });
|
||||||
|
await gradientsCache.fetch({ force: true });
|
||||||
|
showToast(t('color_strip.gradient.preset.deleted'), 'success');
|
||||||
|
} catch (e: any) {
|
||||||
|
showToast(e.message || 'Failed to delete gradient', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Gradient Editor Modal ────────────────────────────────────── */
|
||||||
|
|
||||||
|
class GradientEditorModal extends Modal {
|
||||||
|
constructor() { super('gradient-editor-modal'); }
|
||||||
|
|
||||||
|
snapshotValues() {
|
||||||
|
return {
|
||||||
|
name: (document.getElementById('gradient-editor-name') as HTMLInputElement).value,
|
||||||
|
description: (document.getElementById('gradient-editor-description') as HTMLInputElement).value,
|
||||||
|
stops: JSON.stringify(getGradientStops()),
|
||||||
|
tags: JSON.stringify(_gradientTagsInput ? _gradientTagsInput.getValue() : []),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
onForceClose() {
|
||||||
|
if (_gradientTagsInput) { _gradientTagsInput.destroy(); _gradientTagsInput = null; }
|
||||||
|
gradientSetIdPrefix('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const gradientEditorModal = new GradientEditorModal();
|
||||||
|
let _gradientTagsInput: any = null;
|
||||||
|
|
||||||
|
export async function showGradientModal(editId: string | null = null, cloneData: any = null) {
|
||||||
|
const titleEl = document.getElementById('gradient-editor-title')!;
|
||||||
|
const idInput = document.getElementById('gradient-editor-id') as HTMLInputElement;
|
||||||
|
const nameInput = document.getElementById('gradient-editor-name') as HTMLInputElement;
|
||||||
|
const descInput = document.getElementById('gradient-editor-description') as HTMLInputElement;
|
||||||
|
const errorEl = document.getElementById('gradient-editor-error') as HTMLElement;
|
||||||
|
|
||||||
|
idInput.value = '';
|
||||||
|
nameInput.value = '';
|
||||||
|
descInput.value = '';
|
||||||
|
errorEl.style.display = 'none';
|
||||||
|
|
||||||
|
if (_gradientTagsInput) { _gradientTagsInput.destroy(); _gradientTagsInput = null; }
|
||||||
|
const tagsContainer = document.getElementById('gradient-editor-tags-container')!;
|
||||||
|
_gradientTagsInput = new TagInput(tagsContainer, { placeholder: t('tags.placeholder') });
|
||||||
|
|
||||||
|
let stops = [{ position: 0.0, color: [255, 0, 0] }, { position: 1.0, color: [0, 0, 255] }];
|
||||||
|
|
||||||
|
if (editId) {
|
||||||
|
const g = _findGradient(editId);
|
||||||
|
if (!g) return;
|
||||||
|
idInput.value = g.id;
|
||||||
|
nameInput.value = g.name;
|
||||||
|
descInput.value = g.description || '';
|
||||||
|
_gradientTagsInput.setValue(g.tags || []);
|
||||||
|
stops = g.stops;
|
||||||
|
titleEl.innerHTML = `${ICON_PALETTE} ${t('gradient.edit')}`;
|
||||||
|
} else if (cloneData) {
|
||||||
|
nameInput.value = (cloneData.name || '') + ' (Copy)';
|
||||||
|
descInput.value = cloneData.description || '';
|
||||||
|
_gradientTagsInput.setValue(cloneData.tags || []);
|
||||||
|
stops = cloneData.stops || stops;
|
||||||
|
titleEl.innerHTML = `${ICON_PALETTE} ${t('gradient.add')}`;
|
||||||
|
} else {
|
||||||
|
titleEl.innerHTML = `${ICON_PALETTE} ${t('gradient.add')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
gradientEditorModal.open();
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
gradientSetIdPrefix('ge-');
|
||||||
|
gradientInit(stops);
|
||||||
|
gradientEditorModal.snapshot();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeGradientEditor() {
|
||||||
|
await gradientEditorModal.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveGradientEntity() {
|
||||||
|
const id = (document.getElementById('gradient-editor-id') as HTMLInputElement).value;
|
||||||
|
const name = (document.getElementById('gradient-editor-name') as HTMLInputElement).value.trim();
|
||||||
|
const description = (document.getElementById('gradient-editor-description') as HTMLInputElement).value.trim() || null;
|
||||||
|
const tags = _gradientTagsInput ? _gradientTagsInput.getValue() : [];
|
||||||
|
const errorEl = document.getElementById('gradient-editor-error') as HTMLElement;
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
errorEl.textContent = t('gradient.error.name_required');
|
||||||
|
errorEl.style.display = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stops = getGradientStops().map(s => ({
|
||||||
|
position: s.position,
|
||||||
|
color: [...s.color],
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (stops.length < 2) {
|
||||||
|
errorEl.textContent = t('gradient.error.min_stops');
|
||||||
|
errorEl.style.display = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: any = { name, stops, description, tags };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = id ? `/gradients/${id}` : '/gradients';
|
||||||
|
const method = id ? 'PUT' : 'POST';
|
||||||
|
const res = await fetchWithAuth(url, { method, body: JSON.stringify(payload) });
|
||||||
|
if (!res!.ok) {
|
||||||
|
const err = await res!.json();
|
||||||
|
throw new Error(err.detail || 'Failed to save gradient');
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast(id ? t('gradient.updated') : t('gradient.created'), 'success');
|
||||||
|
gradientsCache.invalidate();
|
||||||
|
gradientEditorModal.forceClose();
|
||||||
|
if (window.loadPictureSources) await window.loadPictureSources();
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.isAuth) return;
|
||||||
|
errorEl.textContent = e.message;
|
||||||
|
errorEl.style.display = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cloneGradient(gradientId: string) {
|
||||||
|
const g = _findGradient(gradientId);
|
||||||
|
if (!g) return;
|
||||||
|
await showGradientModal(null, g);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function editGradient(gradientId: string) {
|
||||||
|
await showGradientModal(gradientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteGradient(gradientId: string) {
|
||||||
|
const g = _findGradient(gradientId);
|
||||||
|
if (!g) return;
|
||||||
|
const ok = await showConfirm(t('gradient.confirm_delete', { name: g.name }));
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
await fetchWithAuth(`/gradients/${gradientId}`, { method: 'DELETE' });
|
||||||
|
gradientsCache.invalidate();
|
||||||
|
showToast(t('gradient.deleted'), 'success');
|
||||||
|
if (window.loadPictureSources) await window.loadPictureSources();
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.isAuth) return;
|
||||||
|
showToast(e.message || t('gradient.error.delete_failed'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,153 @@
|
|||||||
|
/**
|
||||||
|
* Color Strip Sources — Mapped zone helpers.
|
||||||
|
* Extracted from color-strips.ts to reduce file size.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { escapeHtml } from '../../core/api.ts';
|
||||||
|
import { t } from '../../core/i18n.ts';
|
||||||
|
import { getColorStripIcon, ICON_TRASH } from '../../core/icons.ts';
|
||||||
|
import { EntitySelect } from '../../core/entity-palette.ts';
|
||||||
|
|
||||||
|
/* ── State ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
let _mappedZones: any[] = [];
|
||||||
|
let _mappedAvailableSources: any[] = [];
|
||||||
|
let _mappedZoneEntitySelects: any[] = [];
|
||||||
|
|
||||||
|
/* ── Source management ────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export function mappedSetAvailableSources(sources: any[]) {
|
||||||
|
_mappedAvailableSources = sources;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _getMappedSourceItems() {
|
||||||
|
return _mappedAvailableSources.map(s => ({
|
||||||
|
value: s.id,
|
||||||
|
label: s.name,
|
||||||
|
icon: getColorStripIcon(s.source_type),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Destroy ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function _mappedDestroyEntitySelects() {
|
||||||
|
_mappedZoneEntitySelects.forEach(es => es.destroy());
|
||||||
|
_mappedZoneEntitySelects = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Rendering ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export function mappedRenderList() {
|
||||||
|
const list = document.getElementById('mapped-zones-list') as HTMLElement | null;
|
||||||
|
if (!list) return;
|
||||||
|
_mappedDestroyEntitySelects();
|
||||||
|
list.innerHTML = _mappedZones.map((zone, i) => {
|
||||||
|
const srcOptions = _mappedAvailableSources.map(s =>
|
||||||
|
`<option value="${s.id}"${zone.source_id === s.id ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
|
||||||
|
).join('');
|
||||||
|
return `
|
||||||
|
<div class="segment-row">
|
||||||
|
<div class="segment-row-header">
|
||||||
|
<span class="segment-index-label">#${i + 1}</span>
|
||||||
|
<button type="button" class="btn-icon-inline btn-danger-text"
|
||||||
|
onclick="mappedRemoveZone(${i})" title="${t('common.delete')}">${ICON_TRASH}</button>
|
||||||
|
</div>
|
||||||
|
<div class="segment-row-fields">
|
||||||
|
<select class="mapped-zone-source" data-idx="${i}">${srcOptions}</select>
|
||||||
|
<div class="segment-range-fields">
|
||||||
|
<label>${t('color_strip.mapped.zone_start')}</label>
|
||||||
|
<input type="number" class="mapped-zone-start" data-idx="${i}"
|
||||||
|
min="0" value="${zone.start}" placeholder="0">
|
||||||
|
<label>${t('color_strip.mapped.zone_end')}</label>
|
||||||
|
<input type="number" class="mapped-zone-end" data-idx="${i}"
|
||||||
|
min="0" value="${zone.end}" placeholder="0 = auto">
|
||||||
|
</div>
|
||||||
|
<label class="segment-reverse-label">
|
||||||
|
<input type="checkbox" class="mapped-zone-reverse" data-idx="${i}"${zone.reverse ? ' checked' : ''}>
|
||||||
|
<span>${t('color_strip.mapped.zone_reverse')}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Attach EntitySelect to each zone's source dropdown
|
||||||
|
list.querySelectorAll<HTMLSelectElement>('.mapped-zone-source').forEach(sel => {
|
||||||
|
_mappedZoneEntitySelects.push(new EntitySelect({
|
||||||
|
target: sel,
|
||||||
|
getItems: _getMappedSourceItems,
|
||||||
|
placeholder: t('color_strip.mapped.select_source'),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Actions ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export function mappedAddZone() {
|
||||||
|
_mappedZonesSyncFromDom();
|
||||||
|
_mappedZones.push({
|
||||||
|
source_id: _mappedAvailableSources.length > 0 ? _mappedAvailableSources[0].id : '',
|
||||||
|
start: 0,
|
||||||
|
end: 0,
|
||||||
|
reverse: false,
|
||||||
|
});
|
||||||
|
mappedRenderList();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mappedRemoveZone(i: number) {
|
||||||
|
_mappedZonesSyncFromDom();
|
||||||
|
_mappedZones.splice(i, 1);
|
||||||
|
mappedRenderList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── DOM sync ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function _mappedZonesSyncFromDom() {
|
||||||
|
const list = document.getElementById('mapped-zones-list') as HTMLElement | null;
|
||||||
|
if (!list) return;
|
||||||
|
const srcs = list.querySelectorAll<HTMLSelectElement>('.mapped-zone-source');
|
||||||
|
const starts = list.querySelectorAll<HTMLInputElement>('.mapped-zone-start');
|
||||||
|
const ends = list.querySelectorAll<HTMLInputElement>('.mapped-zone-end');
|
||||||
|
const reverses = list.querySelectorAll<HTMLInputElement>('.mapped-zone-reverse');
|
||||||
|
if (srcs.length === _mappedZones.length) {
|
||||||
|
for (let i = 0; i < srcs.length; i++) {
|
||||||
|
_mappedZones[i].source_id = srcs[i].value;
|
||||||
|
_mappedZones[i].start = parseInt(starts[i].value) || 0;
|
||||||
|
_mappedZones[i].end = parseInt(ends[i].value) || 0;
|
||||||
|
_mappedZones[i].reverse = reverses[i].checked;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mappedGetZones() {
|
||||||
|
_mappedZonesSyncFromDom();
|
||||||
|
return _mappedZones.map(z => ({
|
||||||
|
source_id: z.source_id,
|
||||||
|
start: z.start,
|
||||||
|
end: z.end,
|
||||||
|
reverse: z.reverse,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadMappedState(css: any) {
|
||||||
|
const raw = css && css.zones;
|
||||||
|
_mappedZones = (raw && raw.length > 0)
|
||||||
|
? raw.map((z: any) => ({
|
||||||
|
source_id: z.source_id || '',
|
||||||
|
start: z.start || 0,
|
||||||
|
end: z.end || 0,
|
||||||
|
reverse: z.reverse || false,
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
mappedRenderList();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetMappedState() {
|
||||||
|
_mappedZones = [];
|
||||||
|
mappedRenderList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get raw zones array (for snapshot). */
|
||||||
|
export function getMappedZones(): any[] {
|
||||||
|
return _mappedZones;
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
/**
|
||||||
|
* Color Strip Sources — Math Wave helpers.
|
||||||
|
* Extracted from color-strips.ts to reduce file size.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { _cachedValueSources } from '../../core/state.ts';
|
||||||
|
import { t } from '../../core/i18n.ts';
|
||||||
|
import { ICON_TRASH } from '../../core/icons.ts';
|
||||||
|
import * as P from '../../core/icon-paths.ts';
|
||||||
|
import { IconSelect, type IconSelectItem } from '../../core/icon-select.ts';
|
||||||
|
import { EntitySelect } from '../../core/entity-palette.ts';
|
||||||
|
import { BindableScalarWidget } from '../../core/bindable-scalar.ts';
|
||||||
|
|
||||||
|
const _icon = (d: any) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
||||||
|
|
||||||
|
/* ── State ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
let _mathWaveSpeedWidget: BindableScalarWidget | null = null;
|
||||||
|
let _mathWaveGradientEntitySelect: EntitySelect | null = null;
|
||||||
|
let _mathWaveWaveformIconSelects: IconSelect[] = [];
|
||||||
|
|
||||||
|
/* ── Destroy (called from modal onForceClose) ─────────────────── */
|
||||||
|
|
||||||
|
export function destroyMathWaveWidgets() {
|
||||||
|
// Waveform icon selects are destroyed in _mathWaveRenderLayers
|
||||||
|
_mathWaveWaveformIconSelects.forEach(s => s.destroy());
|
||||||
|
_mathWaveWaveformIconSelects = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Speed widget ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export function ensureMathWaveSpeedWidget(): BindableScalarWidget {
|
||||||
|
if (!_mathWaveSpeedWidget) {
|
||||||
|
_mathWaveSpeedWidget = new BindableScalarWidget({
|
||||||
|
container: document.getElementById('css-editor-math-wave-speed-container')!,
|
||||||
|
min: 0.1, max: 10.0, step: 0.1, default: 1.0,
|
||||||
|
idPrefix: 'css-editor-math-wave-speed',
|
||||||
|
valueSources: () => _cachedValueSources,
|
||||||
|
format: (v) => v.toFixed(1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _mathWaveSpeedWidget;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Gradient entity select ───────────────────────────────────── */
|
||||||
|
|
||||||
|
export function ensureMathWaveGradientEntitySelect(buildGradientEntityItems: () => any[], syncSelectOptions: (sel: HTMLSelectElement, items: any[]) => void) {
|
||||||
|
const sel = document.getElementById('css-editor-math-wave-gradient') as HTMLSelectElement | null;
|
||||||
|
if (!sel) return;
|
||||||
|
const items = buildGradientEntityItems();
|
||||||
|
syncSelectOptions(sel, items);
|
||||||
|
if (_mathWaveGradientEntitySelect) { _mathWaveGradientEntitySelect.refresh(); return; }
|
||||||
|
_mathWaveGradientEntitySelect = new EntitySelect({
|
||||||
|
target: sel,
|
||||||
|
getItems: buildGradientEntityItems,
|
||||||
|
placeholder: t('palette.search'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function refreshMathWaveGradientEntitySelect() {
|
||||||
|
if (_mathWaveGradientEntitySelect) _mathWaveGradientEntitySelect.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMathWaveGradientEntitySelect(): EntitySelect | null {
|
||||||
|
return _mathWaveGradientEntitySelect;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Waveform items ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function _buildMathWaveWaveformItems(): IconSelectItem[] {
|
||||||
|
return [
|
||||||
|
{ value: 'sine', icon: _icon(P.activity), label: t('color_strip.math_wave.waveform.sine'), desc: '' },
|
||||||
|
{ value: 'triangle', icon: _icon(P.trendingUp), label: t('color_strip.math_wave.waveform.triangle'), desc: '' },
|
||||||
|
{ value: 'sawtooth', icon: _icon(P.rotateCw), label: t('color_strip.math_wave.waveform.sawtooth'), desc: '' },
|
||||||
|
{ value: 'square', icon: _icon(P.layoutDashboard), label: t('color_strip.math_wave.waveform.square'), desc: '' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Layer rendering ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export type MathWaveLayer = { waveform: string; frequency: number; amplitude: number; phase: number; offset: number };
|
||||||
|
|
||||||
|
export function mathWaveRenderLayers(waves: MathWaveLayer[]) {
|
||||||
|
const container = document.getElementById('css-editor-math-wave-layers');
|
||||||
|
if (!container) return;
|
||||||
|
// Destroy old waveform icon selects
|
||||||
|
_mathWaveWaveformIconSelects.forEach(s => s.destroy());
|
||||||
|
_mathWaveWaveformIconSelects = [];
|
||||||
|
|
||||||
|
container.innerHTML = '';
|
||||||
|
waves.forEach((wave, idx) => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'math-wave-layer-row';
|
||||||
|
row.style.cssText = 'border:1px solid var(--border-color,#444);border-radius:6px;padding:8px;margin-bottom:6px;position:relative';
|
||||||
|
row.innerHTML = `
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
|
||||||
|
<strong>#${idx + 1}</strong>
|
||||||
|
<button type="button" class="btn btn-icon btn-danger btn-sm" onclick="mathWaveRemoveLayer(${idx})" title="${t('common.delete')}" style="margin-left:auto">${ICON_TRASH}</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-bottom:4px">
|
||||||
|
<label style="font-size:0.85em" data-i18n="color_strip.math_wave.waveform">${t('color_strip.math_wave.waveform')}</label>
|
||||||
|
<select class="mw-waveform" data-idx="${idx}">
|
||||||
|
<option value="sine">${t('color_strip.math_wave.waveform.sine')}</option>
|
||||||
|
<option value="triangle">${t('color_strip.math_wave.waveform.triangle')}</option>
|
||||||
|
<option value="sawtooth">${t('color_strip.math_wave.waveform.sawtooth')}</option>
|
||||||
|
<option value="square">${t('color_strip.math_wave.waveform.square')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px">
|
||||||
|
<div class="form-group" style="margin-bottom:0">
|
||||||
|
<label style="font-size:0.85em" data-i18n="color_strip.math_wave.frequency">${t('color_strip.math_wave.frequency')}</label>
|
||||||
|
<input type="number" class="mw-frequency" min="0.1" max="20" step="0.1" value="${wave.frequency}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-bottom:0">
|
||||||
|
<label style="font-size:0.85em" data-i18n="color_strip.math_wave.amplitude">${t('color_strip.math_wave.amplitude')}</label>
|
||||||
|
<input type="number" class="mw-amplitude" min="0.0" max="2.0" step="0.1" value="${wave.amplitude}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-bottom:0">
|
||||||
|
<label style="font-size:0.85em" data-i18n="color_strip.math_wave.phase">${t('color_strip.math_wave.phase')}</label>
|
||||||
|
<input type="number" class="mw-phase" min="0.0" max="6.28" step="0.1" value="${wave.phase}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-bottom:0">
|
||||||
|
<label style="font-size:0.85em" data-i18n="color_strip.math_wave.offset">${t('color_strip.math_wave.offset')}</label>
|
||||||
|
<input type="number" class="mw-offset" min="-1.0" max="1.0" step="0.1" value="${wave.offset}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
container.appendChild(row);
|
||||||
|
|
||||||
|
// Set waveform value and attach IconSelect
|
||||||
|
const wfSelect = row.querySelector('.mw-waveform') as HTMLSelectElement;
|
||||||
|
wfSelect.value = wave.waveform || 'sine';
|
||||||
|
const iconSel = new IconSelect({
|
||||||
|
target: wfSelect,
|
||||||
|
items: _buildMathWaveWaveformItems(),
|
||||||
|
columns: 2,
|
||||||
|
});
|
||||||
|
_mathWaveWaveformIconSelects.push(iconSel);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mathWaveGetLayers(): MathWaveLayer[] {
|
||||||
|
const container = document.getElementById('css-editor-math-wave-layers');
|
||||||
|
if (!container) return [];
|
||||||
|
const rows = container.querySelectorAll('.math-wave-layer-row');
|
||||||
|
return Array.from(rows).map(row => ({
|
||||||
|
waveform: (row.querySelector('.mw-waveform') as HTMLSelectElement).value,
|
||||||
|
frequency: parseFloat((row.querySelector('.mw-frequency') as HTMLInputElement).value) || 1.0,
|
||||||
|
amplitude: parseFloat((row.querySelector('.mw-amplitude') as HTMLInputElement).value) || 1.0,
|
||||||
|
phase: parseFloat((row.querySelector('.mw-phase') as HTMLInputElement).value) || 0.0,
|
||||||
|
offset: parseFloat((row.querySelector('.mw-offset') as HTMLInputElement).value) || 0.0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mathWaveAddLayer() {
|
||||||
|
const current = mathWaveGetLayers();
|
||||||
|
current.push({ waveform: 'sine', frequency: 1.0, amplitude: 1.0, phase: 0.0, offset: 0.0 });
|
||||||
|
mathWaveRenderLayers(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mathWaveRemoveLayer(idx: number) {
|
||||||
|
const current = mathWaveGetLayers();
|
||||||
|
current.splice(idx, 1);
|
||||||
|
mathWaveRenderLayers(current);
|
||||||
|
}
|
||||||
+12
-12
@@ -3,20 +3,20 @@
|
|||||||
* Extracted from color-strips.ts to reduce file size.
|
* Extracted from color-strips.ts to reduce file size.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
import { fetchWithAuth, escapeHtml } from '../../core/api.ts';
|
||||||
import { t } from '../core/i18n.ts';
|
import { t } from '../../core/i18n.ts';
|
||||||
import { showToast } from '../core/ui.ts';
|
import { showToast } from '../../core/ui.ts';
|
||||||
import {
|
import {
|
||||||
ICON_SEARCH, ICON_CLONE, ICON_TRASH, getAssetTypeIcon,
|
ICON_SEARCH, ICON_CLONE, ICON_TRASH, getAssetTypeIcon,
|
||||||
} from '../core/icons.ts';
|
} from '../../core/icons.ts';
|
||||||
import * as P from '../core/icon-paths.ts';
|
import * as P from '../../core/icon-paths.ts';
|
||||||
import { IconSelect } from '../core/icon-select.ts';
|
import { IconSelect } from '../../core/icon-select.ts';
|
||||||
import { EntitySelect } from '../core/entity-palette.ts';
|
import { EntitySelect } from '../../core/entity-palette.ts';
|
||||||
import { attachNotificationAppPicker, NotificationAppPalette } from '../core/process-picker.ts';
|
import { attachNotificationAppPicker, NotificationAppPalette } from '../../core/process-picker.ts';
|
||||||
import { _cachedAssets, _cachedValueSources, assetsCache } from '../core/state.ts';
|
import { _cachedAssets, _cachedValueSources, assetsCache } from '../../core/state.ts';
|
||||||
import { getBaseOrigin } from './settings.ts';
|
import { getBaseOrigin } from '../settings.ts';
|
||||||
import { BindableScalarWidget } from '../core/bindable-scalar.ts';
|
import { BindableScalarWidget } from '../../core/bindable-scalar.ts';
|
||||||
import { BindableColorWidget } from '../core/bindable-color.ts';
|
import { BindableColorWidget } from '../../core/bindable-color.ts';
|
||||||
|
|
||||||
const _icon = (d: any) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
const _icon = (d: any) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
||||||
|
|
||||||
+10
-10
@@ -3,19 +3,19 @@
|
|||||||
* Extracted from color-strips.ts to reduce file size.
|
* Extracted from color-strips.ts to reduce file size.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
import { fetchWithAuth, escapeHtml } from '../../core/api.ts';
|
||||||
import { colorStripSourcesCache } from '../core/state.ts';
|
import { colorStripSourcesCache } from '../../core/state.ts';
|
||||||
import { t } from '../core/i18n.ts';
|
import { t } from '../../core/i18n.ts';
|
||||||
import { showToast, openLightbox, closeLightbox } from '../core/ui.ts';
|
import { showToast, openLightbox, closeLightbox } from '../../core/ui.ts';
|
||||||
import { createFpsSparkline } from '../core/chart-utils.ts';
|
import { createFpsSparkline } from '../../core/chart-utils.ts';
|
||||||
import {
|
import {
|
||||||
getColorStripIcon,
|
getColorStripIcon,
|
||||||
ICON_WARNING, ICON_SUN_DIM,
|
ICON_WARNING, ICON_SUN_DIM,
|
||||||
} from '../core/icons.ts';
|
} from '../../core/icons.ts';
|
||||||
import { EntitySelect } from '../core/entity-palette.ts';
|
import { EntitySelect } from '../../core/entity-palette.ts';
|
||||||
import { hexToRgbArray, getGradientStops } from './css-gradient-editor.ts';
|
import { hexToRgbArray, getGradientStops } from '../css-gradient-editor.ts';
|
||||||
import { testNotification, _getAnimationPayload, _colorCycleGetColors } from './color-strips.ts';
|
import { testNotification, _getAnimationPayload, _colorCycleGetColors } from './index.ts';
|
||||||
import { notificationGetAppColorsDict, notificationGetAppSoundsDict, getNotificationDurationValue, getNotificationVolumeValue } from './color-strips-notification.ts';
|
import { notificationGetAppColorsDict, notificationGetAppSoundsDict, getNotificationDurationValue, getNotificationVolumeValue } from './notification.ts';
|
||||||
|
|
||||||
/* ── Preview config builder ───────────────────────────────────── */
|
/* ── Preview config builder ───────────────────────────────────── */
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ import { getActiveSubTab, setActiveSubTab } from '../core/tab-registry.ts';
|
|||||||
import { createValueSourceCard } from './value-sources.ts';
|
import { createValueSourceCard } from './value-sources.ts';
|
||||||
import { createSyncClockCard, initSyncClockDelegation } from './sync-clocks.ts';
|
import { createSyncClockCard, initSyncClockDelegation } from './sync-clocks.ts';
|
||||||
import { createAssetCard, initAssetDelegation } from './assets.ts';
|
import { createAssetCard, initAssetDelegation } from './assets.ts';
|
||||||
import { createColorStripCard } from './color-strips.ts';
|
import { createColorStripCard } from './color-strips/index.ts';
|
||||||
import { initAudioSourceDelegation } from './audio-sources.ts';
|
import { initAudioSourceDelegation } from './audio-sources.ts';
|
||||||
import { createAudioProcessingTemplateCard } from './audio-processing-templates.ts';
|
import { createAudioProcessingTemplateCard } from './audio-processing-templates.ts';
|
||||||
import {
|
import {
|
||||||
|
|||||||
Reference in New Issue
Block a user