/** @jsxImportSource preact */ import {useEffect, useMemo, useState} from 'preact/hooks'; import { buildInitialCollapsedState, clamp, getSectionKey, mergeCollapsedState, normalizeOption, resolveSettingValue, setValueAtPath } from '../lib/settings/settings'; import {SelectWidgetComponent} from '../widget-components/select-widget-component'; import type { SettingDescriptor, SettingsSchema, SettingsSectionDescriptor, SettingsState, SettingValue } from '../lib/settings/settings'; import type {WidgetPanel, WidgetPanelTheme} from './widget-containers'; import type {JSX} from 'preact'; type SettingsPanelChangeHandler = ( settings: SettingsState, changedSettings?: Array<{ name: string; previousValue: unknown; nextValue: unknown; descriptor?: SettingDescriptor; }> ) => void; /** Settings panel configuration for sidebar/modal container composition. */ export type SettingsPanelProps = { /** Stable panel id used by parent containers. */ id?: string; /** Fallback title used when the schema does not provide one. */ label?: string; /** Descriptor schema rendered by the settings panel. */ schema?: SettingsSchema; /** Current settings values shown and edited by the panel. */ settings?: SettingsState; /** Called when a setting value changes. */ onSettingsChange?: SettingsPanelChangeHandler; /** Optional theme override applied to this panel subtree. */ theme?: WidgetPanelTheme; }; const SECTION_TOGGLE_STYLE: JSX.CSSProperties = { width: '100%', border: 0, margin: 0, padding: '8px 12px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px', background: 'transparent', color: 'inherit', cursor: 'pointer', fontSize: '12px' }; const SECTION_CONTENT_STYLE: JSX.CSSProperties = { display: 'grid', gap: '8px', padding: '8px 12px 12px 18px' }; const SECTION_PANEL_CONTENT_STYLE: JSX.CSSProperties = { display: 'grid', gap: '8px', padding: '10px 12px 12px' }; const SETTING_ROW_STYLE: JSX.CSSProperties = { display: 'grid', gridTemplateColumns: 'minmax(120px, 1fr) minmax(200px, 1.4fr)', alignItems: 'center', gap: '8px' }; const SETTING_LABEL_STYLE: JSX.CSSProperties = { fontSize: '12px', fontWeight: 600, color: 'var(--button-text, currentColor)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }; const SETTING_CONTROL_STYLE: JSX.CSSProperties = { minWidth: 0, display: 'flex', alignItems: 'center' }; const INPUT_STYLE: JSX.CSSProperties = { width: '100%', border: 'var(--button-inner-stroke, 1px solid rgba(128, 128, 128, 0.35))', borderRadius: 'calc(var(--button-corner-radius, 8px) - 2px)', backgroundColor: 'var(--button-background, #fff)', backdropFilter: 'var(--button-backdrop-filter, unset)', color: 'var(--button-text, currentColor)', fontSize: '12px', padding: '4px 6px', boxSizing: 'border-box' }; const STRING_CONTROL_STYLE: JSX.CSSProperties = { ...INPUT_STYLE, flex: 1 }; const STRING_APPLY_BUTTON_STYLE: JSX.CSSProperties = { border: 'var(--button-inner-stroke, 1px solid rgba(128, 128, 128, 0.35))', borderRadius: 'calc(var(--button-corner-radius, 8px) - 2px)', backgroundColor: 'var(--button-background, #fff)', color: 'var(--button-text, currentColor)', fontSize: '11px', padding: '4px 8px', cursor: 'pointer', whiteSpace: 'nowrap' }; const RANGE_INPUT_STYLE: JSX.CSSProperties = { width: '100%', minWidth: '120px', margin: 0 }; const NUMBER_INPUT_STYLE: JSX.CSSProperties = { ...INPUT_STYLE, width: '84px', flexShrink: 0 }; const CHECKBOX_STYLE: JSX.CSSProperties = { width: '14px', height: '14px', margin: 0, accentColor: 'var(--button-icon-hover, currentColor)' }; function stopPropagation(event: Event) { event.stopPropagation(); } function stopPropagationForInput(event: Event) { event.stopPropagation(); if ( typeof (event as {stopImmediatePropagation?: () => void}).stopImmediatePropagation === 'function' ) { (event as {stopImmediatePropagation?: () => void}).stopImmediatePropagation?.(); } } type SettingsControlProps = { setting: SettingDescriptor; value: SettingValue; onValueChange: (nextValue: SettingValue) => void; }; type StringSettingControlProps = { inputId: string; label: string; value: string; onApply: (nextValue: string) => void; }; function StringSettingControl({inputId, label, value, onApply}: StringSettingControlProps) { const [pendingValue, setPendingValue] = useState(value); const [recentValues, setRecentValues] = useState(() => (value ? [value] : [])); const isDirty = pendingValue !== value; useEffect(() => { setPendingValue(value); }, [value]); useEffect(() => { if (!value) { return; } setRecentValues((previous) => previous.includes(value) ? previous : [value, ...previous].slice(0, 8) ); }, [value]); const handlePendingTextChange: JSX.GenericEventHandler = (event) => { setPendingValue(event.currentTarget.value); }; const applyPendingText = () => { if (pendingValue === value) { return; } onApply(pendingValue); setRecentValues((previous) => [pendingValue, ...previous.filter((entry) => entry !== pendingValue)].slice(0, 8) ); }; const handleTextCommit = (event: KeyboardEvent) => { if (event.key === 'Enter') { event.preventDefault(); applyPendingText(); } stopPropagationForInput(event); }; return (
{recentValues.length > 0 && ( {recentValues.map((recentValue) => ( )}
); } // eslint-disable-next-line complexity function SettingsControl({setting, value, onValueChange}: SettingsControlProps) { const label = setting.label ?? setting.name; const tooltip = setting.description?.trim(); const inputId = `settings-panel-input-${setting.name.replace(/[^a-zA-Z0-9_-]/g, '-')}`; const handleBooleanChange: JSX.GenericEventHandler = (event) => { onValueChange(event.currentTarget.checked); }; const handleNumberChange = (nextValue: number) => { if (!Number.isFinite(nextValue)) { return; } onValueChange(clamp(nextValue, setting.min, setting.max)); }; let control: JSX.Element; if (setting.type === 'boolean') { control = ( ); } else if (setting.type === 'number') { const numericValue = Number(value); const showRange = Number.isFinite(setting.min) && Number.isFinite(setting.max); control = showRange ? (
handleNumberChange(Number(event.currentTarget.value))} onChange={(event) => handleNumberChange(Number(event.currentTarget.value))} aria-label={label} style={RANGE_INPUT_STYLE} /> handleNumberChange(Number(event.currentTarget.value))} onChange={(event) => handleNumberChange(Number(event.currentTarget.value))} aria-label={`${label} numeric value`} style={NUMBER_INPUT_STYLE} />
) : ( handleNumberChange(Number(event.currentTarget.value))} onChange={(event) => handleNumberChange(Number(event.currentTarget.value))} aria-label={label} style={INPUT_STYLE} /> ); } else if (setting.type === 'select') { const normalizedOptions = (setting.options ?? []).map(normalizeOption); control = ( ); } else { control = ( ); } return (
{control}
); } type SettingsPanelContentProps = { schema: SettingsSchema; settings: SettingsState; onSettingsChange?: SettingsPanelChangeHandler; }; type SettingsSectionBodyProps = { contentStyle: JSX.CSSProperties; onValueChange: (path: string, nextValue: SettingValue) => void; section: SettingsSectionDescriptor; settings: SettingsState; }; type SettingsSectionPanelContentProps = { onSettingsChange?: SettingsPanelChangeHandler; section: SettingsSectionDescriptor; settings: SettingsState; }; /** * Renders the controls for one settings schema section without section heading chrome. */ function SettingsSectionBody({ contentStyle, onValueChange, section, settings }: SettingsSectionBodyProps) { return (
{section.settings.map((setting) => ( onValueChange(setting.name, nextValue)} /> ))}
); } /** * Renders one settings schema section as direct panel content for generic widget containers. */ function SettingsSectionPanelContent({ onSettingsChange, section, settings }: SettingsSectionPanelContentProps) { const [localSettings, setLocalSettings] = useState(settings); useEffect(() => { setLocalSettings(settings); }, [settings]); const updateSetting = (path: string, nextValue: SettingValue) => { setLocalSettings((previous) => { const nextSettings = setValueAtPath(previous, path, nextValue); onSettingsChange?.(nextSettings); return nextSettings; }); }; return (
stopPropagation(event as unknown as Event)} onMouseMove={(event) => stopPropagation(event as unknown as Event)} onPointerDown={(event) => stopPropagation(event as unknown as Event)} onMouseDown={(event) => stopPropagation(event as unknown as Event)} onWheel={(event) => stopPropagation(event as unknown as Event)} onClick={(event) => stopPropagation(event as unknown as Event)} >
); } const DEFAULT_SETTINGS_PANEL_SCHEMA: SettingsSchema = {sections: []}; const DEFAULT_SETTINGS_PANEL_STATE: SettingsState = {}; /** * Shared settings body used by both the legacy popover widget and panel-based containers. */ export function SettingsPanelContent({ schema, settings, onSettingsChange }: SettingsPanelContentProps) { const [localSettings, setLocalSettings] = useState(settings); const [collapsedState, setCollapsedState] = useState>(() => buildInitialCollapsedState(schema.sections) ); useEffect(() => { setLocalSettings(settings); }, [settings]); useEffect(() => { setCollapsedState((previous) => mergeCollapsedState(previous, schema.sections)); }, [schema.sections]); const sectionEntries = useMemo( () => schema.sections.map((section, index) => ({ key: getSectionKey(section, index), section })), [schema.sections] ); const renderInlineSingleSection = sectionEntries.length === 1 && !sectionEntries[0].section.name && !sectionEntries[0].section.description; const updateSetting = (path: string, nextValue: SettingValue) => { setLocalSettings((previous) => { const nextSettings = setValueAtPath(previous, path, nextValue); onSettingsChange?.(nextSettings); return nextSettings; }); }; return (
stopPropagation(event as unknown as Event)} onMouseMove={(event) => stopPropagation(event as unknown as Event)} onPointerDown={(event) => stopPropagation(event as unknown as Event)} onMouseDown={(event) => stopPropagation(event as unknown as Event)} onWheel={(event) => stopPropagation(event as unknown as Event)} onClick={(event) => stopPropagation(event as unknown as Event)} > {renderInlineSingleSection ? ( ) : ( sectionEntries.map(({key, section}) => { const isCollapsed = collapsedState[key] ?? false; return (
{!isCollapsed && ( )}
); }) )}
); } /** * A reusable settings panel that can be mounted inside modal and sidebar widget containers. */ export class SettingsPanel implements WidgetPanel { id: string; title: string; theme?: WidgetPanelTheme; content: JSX.Element; /** * Creates one widget panel per top-level settings schema section for generic composition. */ static createSectionPanels({ label = 'Settings', schema = DEFAULT_SETTINGS_PANEL_SCHEMA, settings = DEFAULT_SETTINGS_PANEL_STATE, onSettingsChange, theme = 'inherit' }: Omit): Record { return schema.sections.reduce>((panels, section, index) => { const panelId = getSectionKey(section, index); panels[panelId] = { id: panelId, title: section.name || label, theme, content: ( ) }; return panels; }, {}); } constructor({ id = 'settings-panel', label = 'Settings', schema = DEFAULT_SETTINGS_PANEL_SCHEMA, settings = DEFAULT_SETTINGS_PANEL_STATE, onSettingsChange, theme = 'inherit' }: SettingsPanelProps = {}) { this.id = id; this.title = schema.title ?? label; this.theme = theme; this.content = ( ); } }