Files
HomeAssistantVS/custom_components/ha_creality_ws/www/k_printer_card.js
T

1795 lines
87 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const CARD_TAG = "k-printer-card";
const EDITOR_TAG = "k-printer-card-editor";
const I18N_URL_BASE = "/ha_creality_ws/i18n/";
const _i18nData = {};
const _i18nPromises = {};
function _loadI18n(lang) {
if (_i18nData[lang]) return Promise.resolve(_i18nData[lang]);
if (_i18nPromises[lang]) return _i18nPromises[lang];
_i18nPromises[lang] = fetch(`${I18N_URL_BASE}${lang}.json`)
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (data) _i18nData[lang] = data;
else _i18nPromises[lang] = null;
return data;
})
.catch(() => { _i18nPromises[lang] = null; return null; });
return _i18nPromises[lang];
}
function _resolveLang(hass) {
return hass?.locale?.language || hass?.language || "en";
}
function _translate(hass, section, fallbackDict, key) {
const lang = _resolveLang(hass);
const short = lang.split("-")[0];
const remote = _i18nData[lang]?.[section] ?? _i18nData[short]?.[section];
if (remote && key in remote) return remote[key];
const remoteEn = _i18nData["en"]?.[section];
if (remoteEn && key in remoteEn) return remoteEn[key];
return fallbackDict[lang]?.[key] ?? fallbackDict[short]?.[key] ?? fallbackDict["en"]?.[key] ?? key;
}
function _requestI18n(instance, hass, onLoaded) {
if (instance._i18nRequested) return;
instance._i18nRequested = true;
const lang = _resolveLang(hass).split("-")[0];
Promise.all([_loadI18n("en"), lang !== "en" ? _loadI18n(lang) : null]).then(onLoaded);
}
// Per-card-id throttle for ll-rebuild dispatches. ll-rebuild may cause Lovelace to
// recreate the element, in which case instance state (this._cardSize) is reset. A
// module-level record survives recreation and prevents a measure→dispatch→recreate
// loop if size measurement on the fresh instance keeps producing the same delta.
const LL_REBUILD_MIN_INTERVAL_MS = 2000;
const _lastCardRebuildDispatch = new Map();
const clamp = (v, a, b) => Math.min(Math.max(v, a), b);
const mdi = (name) => `mdi:${name}`;
const normStr = (x) => String(x ?? "").toLowerCase();
// Theme persistence utilities
const THEME_STORAGE_KEY = "k-printer-card-themes";
// Color conversion utilities
function rgbaToHex(rgba) {
if (!rgba || rgba.startsWith('#')) return rgba;
// Handle rgba() format
const match = rgba.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*[\d.]+)?\)/);
if (match) {
const r = parseInt(match[1]);
const g = parseInt(match[2]);
const b = parseInt(match[3]);
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
}
// Handle CSS variables
if (rgba.startsWith('var(')) {
return '#000000'; // fallback
}
return '#000000';
}
function hexToRgba(hex, alpha = 1) {
if (!hex || !hex.startsWith('#')) return hex;
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
/**
* Save theme configuration to localStorage for persistence across card updates
* @param {string} cardId - Unique identifier for the card
* @param {Object} theme - Theme configuration object
*/
function saveThemeToStorage(cardId, theme) {
try {
const themes = JSON.parse(localStorage.getItem(THEME_STORAGE_KEY) || "{}");
themes[cardId] = theme;
localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify(themes));
} catch (e) {
console.warn("Failed to save theme to localStorage:", e);
}
}
/**
* Load theme configuration from localStorage
* @param {string} cardId - Unique identifier for the card
* @returns {Object|null} Theme configuration or null if not found
*/
function loadThemeFromStorage(cardId) {
try {
const themes = JSON.parse(localStorage.getItem(THEME_STORAGE_KEY) || "{}");
return themes[cardId] || null;
} catch (e) {
console.warn("Failed to load theme from localStorage:", e);
return null;
}
}
/**
* Generate a unique card ID based on configuration
* @param {Object} config - Card configuration
* @returns {string} Unique card identifier
*/
function generateCardId(config) {
// Generate a unique ID based on the card configuration
const key = `${config.name || "printer"}-${config.status || "unknown"}`;
return btoa(key).replace(/[^a-zA-Z0-9]/g, '').substring(0, 16);
}
function fmtTimeLeft(seconds) {
// Floor to whole seconds so a fractional value (some firmwares report a float)
// renders as e.g. 2:25 instead of 2:25.6789 and doesn't reflow the row every poll.
const s = Math.floor(Number(seconds) || 0);
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
if (m > 0) return `${m}:${String(sec).padStart(2, "0")}`;
return `${sec}s`;
}
function computeIcon(status) {
const st = normStr(status);
if (["off", "unknown", "stopped"].includes(st)) return mdi("printer-3d-off");
if (["printing", "resuming", "pausing", "paused"].includes(st)) return mdi("printer-3d-nozzle");
if (st === "error") return mdi("close-octagon");
if (st === "self-testing") return mdi("cogs");
return mdi("printer-3d");
}
function computeColor(status) {
const st = normStr(status);
if (["off", "unknown", "stopped"].includes(st)) return "var(--secondary-text-color)";
if (["paused", "pausing"].includes(st)) return "#fc6d09";
if (st === "error") return "var(--error-color)";
if (["printing", "resuming", "processing"].includes(st)) return "var(--primary-color)";
if (["idle", "completed"].includes(st)) return "var(--success-color, #4caf50)";
if (st === "self-testing") return "var(--info-color, #2196f3)";
return "var(--secondary-text-color)";
}
class KPrinterCard extends HTMLElement {
static getStubConfig() {
return {
name: "3D Printer",
camera: "", status: "", progress: "", time_left: "",
nozzle: "", bed: "", box: "",
// Optional power switch entity and visibility flag for a Power button
power: "",
show_power_button: true,
layer: "", total_layers: "",
light: "", pause_btn: "", resume_btn: "", stop_btn: "",
// Theme customization options
theme: {
// Button backgrounds
pause_bg: "rgba(252, 109, 9, .90)",
resume_bg: "rgba(76, 175, 80, .90)",
stop_bg: "rgba(244, 67, 54, .95)",
light_on_bg: "rgba(255, 235, 59, .95)",
light_off_bg: "rgba(150,150,150,.35)",
// Button icon colors
pause_icon: "#fff",
resume_icon: "#fff",
stop_icon: "#fff",
light_icon_on: "#000",
light_icon_off: "#000",
// Status icon and progress circle
status_icon: "auto", // auto, or specific color
progress_ring: "auto", // auto, or specific color
status_bg: "auto", // auto (transparent), or specific color
// Telemetry colors
telemetry_icon: "auto", // auto (inherit theme), or specific color
telemetry_text: "auto", // auto (inherit theme), or specific color
// Custom button colors
custom_bg: "rgba(33, 150, 243, .90)",
custom_icon: "#fff",
},
// Config for custom button
custom_btn: "",
custom_btn_icon: "", // Moved to theme editor but stored here
custom_btn_hidden: false,
// Order of buttons
button_order: ['pause', 'resume', 'stop', 'light', 'power', 'custom'],
// Icon overrides
pause_btn_icon: "",
resume_btn_icon: "",
stop_btn_icon: "",
light_btn_icon: "",
power_btn_icon: "",
// Feature toggles
hide_box_temp: false
};
}
static getConfigElement() {
const editor = document.createElement(EDITOR_TAG);
return editor;
}
setConfig(config) {
const defaultConfig = KPrinterCard.getStubConfig();
this._cfg = { ...defaultConfig, ...(config || {}) };
// Init optimistic state overrides map
if (!this._optimisticStates) this._optimisticStates = {};
// Generate card ID for theme persistence
this._cardId = generateCardId(this._cfg);
// Load saved theme if no theme is provided in config
if (!config?.theme) {
const savedTheme = loadThemeFromStorage(this._cardId);
if (savedTheme) {
this._cfg.theme = { ...defaultConfig.theme, ...savedTheme };
}
} else {
// Deep merge theme configuration
this._cfg.theme = { ...defaultConfig.theme, ...config.theme };
// Save theme to storage
saveThemeToStorage(this._cardId, this._cfg.theme);
}
if (!this._root) {
this._root = this.attachShadow({ mode: "open" });
}
// Always re-render when config changes to apply new theme
this._render();
// Apply theme after render to ensure DOM is ready
this._applyTheme();
}
_applyTheme() {
if (!this._root || !this._cfg.theme) {
return;
}
// Re-render with updated CSS to apply theme changes
this._render();
}
// i18n helpers -------------------------------------------------------
_resolveLanguage() {
return _resolveLang(this._hass);
}
_t(key) {
return _translate(this._hass, "printer_card", CARD_TRANSLATIONS, key);
}
// ---------------------------------------------------------------------
set hass(hass) {
this._hass = hass;
_requestI18n(this, hass, () => { if (this._root) this._update(); });
if (this._root) {
// Apply theme first, then update
this._applyTheme();
this._update();
// Schedule a follow-up update shortly after initial attach to absorb entity states once Home Assistant populates them
clearTimeout(this._initialUpdateTimer);
this._initialUpdateTimer = setTimeout(() => {
try { this._update(); } catch (_) { }
}, 150);
}
}
getCardSize() { return this._cardSize || 3; }
_render() {
if (!this._root) return;
// Ensure theme is always properly initialized
const defaultConfig = KPrinterCard.getStubConfig();
this._cfg.theme = { ...defaultConfig.theme, ...(this._cfg.theme || {}) };
// Apply theme variables to CSS custom properties
const theme = this._cfg.theme;
// Theme CSS custom properties - embedded directly in CSS
const themeCSS = `
:host {
--pause-bg: ${theme.pause_bg || 'rgba(252, 109, 9, .90)'};
--resume-bg: ${theme.resume_bg || 'rgba(76, 175, 80, .90)'};
--stop-bg: ${theme.stop_bg || 'rgba(244, 67, 54, .95)'};
--light-on-bg: ${theme.light_on_bg || 'rgba(255, 235, 59, .95)'};
--light-off-bg: ${theme.light_off_bg || 'rgba(150,150,150,.35)'};
--pause-icon: ${theme.pause_icon || '#fff'};
--resume-icon: ${theme.resume_icon || '#fff'};
--stop-icon: ${theme.stop_icon || '#fff'};
--light-icon-on: ${theme.light_icon_on || '#000'};
--light-icon-off: ${theme.light_icon_off || '#000'};
--status-bg: ${theme.status_bg === 'auto' ? 'radial-gradient(var(--card-background-color) 62%, transparent 0)' : (theme.status_bg || 'radial-gradient(var(--card-background-color) 62%, transparent 0)')};
--telemetry-icon: ${theme.telemetry_icon === 'auto' ? 'var(--secondary-text-color)' : (theme.telemetry_icon || 'var(--secondary-text-color)')};
--telemetry-text: ${theme.telemetry_text === 'auto' ? 'var(--primary-text-color)' : (theme.telemetry_text || 'var(--primary-text-color)')};
--custom-bg: ${theme.custom_bg || 'rgba(33, 150, 243, .90)'};
--custom-on-bg: ${theme.custom_on_bg || theme.custom_bg || 'rgba(33, 150, 243, .90)'};
--custom-off-bg: ${theme.custom_off_bg || 'rgba(150,150,150,.35)'};
--custom-icon: ${theme.custom_icon || '#fff'};
--custom-icon-off: ${theme.custom_icon_off || '#000'};
}
`;
const style = `
/* Theme CSS custom properties */
${themeCSS}
/* inherit HA fonts & typography */
:host { font: inherit; color: var(--primary-text-color); }
/* unify horizontal padding so right edges line up */
:host { --row-xpad: 6px; }
.card {
color: var(--primary-text-color);
padding: 10px var(--row-xpad) 10px var(--row-xpad);
display: grid;
grid-template-rows: auto auto;
gap: 6px;
}
/* top row */
.row-top {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 8px;
padding: 0 var(--row-xpad);
}
.title { display:flex; align-items:center; gap:10px; min-height:44px; }
/* icon + progress ring */
.shape { position:relative; width:40px; height:40px; border-radius:50%;
display:grid; place-items:center;
background: var(--status-bg, radial-gradient(var(--card-background-color) 62%, transparent 0));
}
.ring { position:absolute; inset:0; border-radius:50%;
mask: radial-gradient(circle at 50% 50%, transparent 54%, black 55%);
-webkit-mask: radial-gradient(circle at 50% 50%, transparent 54%, black 55%);
background: conic-gradient(var(--ring-color, var(--primary-color)) var(--ring-pct,0%),
rgba(128,128,128,.25) var(--ring-pct,0%));
}
ha-icon { --mdc-icon-size:24px; width:24px; height:24px; color: var(--icon-color); }
.name { font-weight:600; font-size:.95rem; line-height:1.2; }
.secondary { color:var(--secondary-text-color); font-size:.8rem; }
/* action chips – align right edge to telemetry via the same side padding */
.chips {
display:flex; gap:8px; justify-content:flex-end; flex-wrap:wrap;
padding: 0 var(--row-xpad);
}
.chip {
display:inline-flex; align-items:center; justify-content:center;
gap:6px; min-width:38px; height:34px;
border-radius:18px; padding:0 10px;
font-size:.8rem; background:var(--chip-bg, rgba(128,128,128,.14));
color:var(--chip-fg, var(--primary-text-color));
cursor:pointer; user-select:none; border:none; outline:none;
}
.chip[hidden]{ display:none !important; }
.chip:active { transform: translateY(1px); }
.chip.danger { --chip-bg: var(--stop-bg, rgba(244, 67, 54, .95)); --chip-fg: var(--stop-icon, #fff); }
.chip.warn { --chip-bg: var(--pause-bg, rgba(252, 109, 9, .90)); --chip-fg: var(--pause-icon, #fff); }
.chip.ok { --chip-bg: var(--resume-bg, rgba(76, 175, 80, .90)); --chip-fg: var(--resume-icon, #fff); }
.chip.light-on { --chip-bg: var(--light-on-bg, rgba(255, 235, 59, .95)); --chip-fg: var(--light-icon-on, #000); }
.chip.light-off { --chip-bg: var(--light-off-bg, rgba(150,150,150,.35)); --chip-fg: var(--light-icon-off, #000); }
.chip.power-on { --chip-bg: var(--power-on-bg, rgba(76, 175, 80, .90)); --chip-fg: var(--power-icon-on, #fff); }
.chip.power-off { --chip-bg: var(--power-off-bg, rgba(150,150,150,.35)); --chip-fg: var(--power-icon-off, #000); }
.chip.unknown { --chip-bg: rgba(128,128,128,.14); --chip-fg: var(--primary-text-color); }
.chip.custom { --chip-bg: var(--custom-bg, rgba(33, 150, 243, .90)); --chip-fg: var(--custom-icon, #fff); }
.chip.custom-on { --chip-bg: var(--custom-on-bg, var(--custom-bg, rgba(33, 150, 243, .90))); --chip-fg: var(--custom-icon, #fff); }
.chip.custom-off{ --chip-bg: var(--custom-off-bg, rgba(150,150,150,.35)); --chip-fg: var(--custom-icon-off, #000); }
/* Order is handled by flex order style or DOM order */
/* telemetry row – single line, same right padding, tighter pills */
.telemetry {
display:flex;
gap:6px;
justify-content:center;
flex-wrap:wrap;
padding: 0 var(--row-xpad);
min-width:0;
}
.pill {
display:inline-flex; align-items:center; gap:6px;
padding:6px 10px; border-radius:14px;
background:rgba(127,127,127,.12);
font-size:.8rem; border:1px solid rgba(255,255,255,0.08);
white-space:nowrap; flex:0 0 auto;
color: var(--telemetry-text, var(--primary-text-color));
}
.pill ha-icon { --mdc-icon-size:16px; width:16px; height:16px; color: var(--telemetry-icon, var(--secondary-text-color)); }
.click { cursor:pointer; }
`;
this._root.innerHTML = `
<ha-card class="card">
<style>${style}</style>
<div class="row-top">
<div class="title click" id="more" role="button" tabindex="0">
<div class="shape">
<div class="ring" id="ring"></div>
<ha-icon id="icon"></ha-icon>
</div>
<div>
<div class="name" id="name"></div>
<div class="secondary" id="secondary"></div>
</div>
</div>
<div class="chips" id="chips-container">
<!-- Buttons will be injected here based on order -->
</div>
</div>
<div class="telemetry">
<div class="pill"><ha-icon icon="mdi:printer-3d-nozzle-heat"></ha-icon><span id="nozzle"></span></div>
<div class="pill"><ha-icon icon="mdi:heating-coil"></ha-icon><span id="bed"></span></div>
<div class="pill" id="box-pill"><ha-icon icon="mdi:thermometer"></ha-icon><span id="box"></span></div>
<div class="pill"><ha-icon icon="mdi:progress-clock"></ha-icon><span id="time"></span></div>
<div class="pill"><ha-icon icon="mdi:layers-triple"></ha-icon><span id="layers"></span></div>
</div>
</ha-card>
`;
// events
const fireMoreInfo = (eid) => {
if (!eid) return;
this.dispatchEvent(new CustomEvent("hass-more-info", {
detail: { entityId: eid },
bubbles: true,
composed: true,
}));
};
this._root.getElementById("more")?.addEventListener("click", () => {
const eid = this._cfg.camera || this._cfg.status || this._cfg.progress;
fireMoreInfo(eid);
});
this._root.getElementById("more")?.addEventListener("keydown", (ev) => {
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
const eid = this._cfg.camera || this._cfg.status || this._cfg.progress;
fireMoreInfo(eid);
}
});
// --- Chip/Button Event Delegation ---
const chipsContainer = this._root.getElementById("chips-container");
chipsContainer.addEventListener("click", (e) => {
const btn = e.target.closest("button.chip");
if (!btn) return;
const id = btn.id;
if (id === "power") {
const eid = this._resolveEntityId(this._cfg.power, ["switch"]);
// Confirm only when the press would turn the printer OFF (not when
// turning it on), with a stronger warning while a print is in progress
// so an accidental tap can't kill a running job.
if (this._hass?.states?.[eid]?.state === "on") {
const st = normStr(this._hass?.states?.[this._cfg.status]?.state);
const printing = ["printing", "resuming", "pausing", "paused"].includes(st);
const msg = printing ? this._t("confirm_power_off_printing") : this._t("confirm_power_off");
if (!confirm(msg)) return;
}
this._toggleEntity(eid);
} else if (id === "light") {
const eid = this._resolveEntityId(this._cfg.light, ["light", "switch"]);
this._toggleEntity(eid);
} else if (id === "pause") {
this._pressButtonEntity(this._cfg.pause_btn);
} else if (id === "resume") {
this._pressButtonEntity(this._cfg.resume_btn);
} else if (id === "stop") {
if (confirm(this._t("confirm_stop"))) {
this._pressButtonEntity(this._cfg.stop_btn);
}
} else if (id === "custom") {
// Custom button can be a button (press), script (turn_on/run), switch (toggle), automation (trigger), etc.
// For simplicity, treat as toggle if switch/light/input_boolean, else press/turn_on
const eid = this._cfg.custom_btn;
const domain = eid ? (eid.split(".")[0] || "").toLowerCase() : "";
if (["switch", "light", "input_boolean"].includes(domain)) {
this._toggleEntity(eid);
} else {
this._pressButtonEntity(eid); // Fallback to press (works for button domain, or generic turn_on if mapped)
}
}
});
this._update();
this._setupTelemetrySizeObserver();
}
connectedCallback() {
// The card may be re-attached without setConfig firing again (e.g. when Lovelace
// moves the element between containers). _render() — which normally wires up the
// telemetry observer — only runs from setConfig/hass paths, so reinstate the
// observer here whenever a previously-rendered card returns to the DOM.
if (this._root) {
this._setupTelemetrySizeObserver();
}
}
disconnectedCallback() {
clearTimeout(this._initialUpdateTimer);
if (this._telemetryResizeObserver) {
if (this._telemetryObservedNode) {
this._telemetryResizeObserver.unobserve(this._telemetryObservedNode);
this._telemetryObservedNode = null;
}
this._telemetryResizeObserver.disconnect();
this._telemetryResizeObserver = null;
}
if (this._telemetrySizeFrame) {
cancelAnimationFrame(this._telemetrySizeFrame);
this._telemetrySizeFrame = null;
}
}
_setupTelemetrySizeObserver() {
const telemetry = this._root?.querySelector(".telemetry");
if (!telemetry) return;
if (typeof ResizeObserver !== "function") {
this._scheduleTelemetrySizeUpdate();
return;
}
if (!this._telemetryResizeObserver) {
this._telemetryResizeObserver = new ResizeObserver(() => this._scheduleTelemetrySizeUpdate());
}
if (this._telemetryObservedNode && this._telemetryObservedNode !== telemetry) {
this._telemetryResizeObserver.unobserve(this._telemetryObservedNode);
}
if (this._telemetryObservedNode !== telemetry) {
this._telemetryResizeObserver.observe(telemetry);
this._telemetryObservedNode = telemetry;
}
this._scheduleTelemetrySizeUpdate();
}
_scheduleTelemetrySizeUpdate() {
if (this._telemetrySizeFrame) {
cancelAnimationFrame(this._telemetrySizeFrame);
}
this._telemetrySizeFrame = requestAnimationFrame(() => {
this._telemetrySizeFrame = null;
this._updateTelemetryCardSize();
});
}
_updateTelemetryCardSize() {
const telemetry = this._root?.querySelector(".telemetry");
if (!telemetry) return;
const lineTops = new Set(
Array.from(telemetry.children)
.filter((pill) => {
const style = getComputedStyle(pill);
return style.display !== "none" && style.visibility !== "hidden";
})
.map((pill) => Math.round(pill.offsetTop)),
);
const nextSize = Math.max(3, 2 + lineTops.size);
const currentSize = this._cardSize ?? 3;
if (nextSize === currentSize) return;
const cardKey = this._cardId || CARD_TAG;
const now = Date.now();
const lastDispatch = _lastCardRebuildDispatch.get(cardKey) || 0;
// Defer the _cardSize update until the throttle clears: otherwise a throttled
// call would record the new size locally without telling Lovelace, and the
// next measurement would short-circuit on the equality check above — leaving
// the rebuild permanently suppressed.
if (now - lastDispatch < LL_REBUILD_MIN_INTERVAL_MS) return;
_lastCardRebuildDispatch.set(cardKey, now);
this._cardSize = nextSize;
this.dispatchEvent(new CustomEvent("ll-rebuild", { bubbles: true, composed: true }));
}
// Re-implement _pressButtonEntity to be smarter about non-button domains if needed,
// but existing implementation calls button.press.
// For custom buttons (e.g. scripts), we might want to default to homeassistant.turn_on if button.press fails is overkill,
// but let's keep it simple: if it's a script/automation, button.press might not work.
// Let's refine _pressButtonEntity to handle more types or create a generic helper.
async _pressButtonEntity(eid) {
if (!this._hass || !eid) return;
const domain = eid.split(".")[0];
if (domain === "button" || domain === "input_button") {
await this._hass.callService(domain, "press", { entity_id: eid });
} else {
// Fallback for scripts, automations, scenes which act like "press" via turn_on
await this._hass.callService("homeassistant", "turn_on", { entity_id: eid });
}
}
async _toggleEntity(eid) {
if (!this._hass || !eid) return;
const st = this._hass.states[eid];
const domain = (eid.split(".")[0] || "").toLowerCase();
if (domain === "switch" || domain === "light") {
const next = st?.state === "on" ? "off" : "on";
// Only apply optimistic UI to the power switch; Light strictly reflects HA state
const resolvedPower = this._resolveEntityId(this._cfg.power, ["switch"]);
if (resolvedPower && eid === resolvedPower) {
const now = Date.now();
this._optimisticStates = this._optimisticStates || {};
this._optimisticStates[eid] = { state: next, until: now + 2000 };
this._update();
}
await this._hass.callService(domain, next === "on" ? "turn_on" : "turn_off", { entity_id: eid });
} else {
await this._hass.callService("homeassistant", "toggle", { entity_id: eid });
}
}
_resolveEntityId(eid, preferredDomains = []) {
if (!eid || !this._hass) return eid;
if (this._hass.states[eid]) return eid;
const parts = String(eid).split(".");
if (parts.length === 2) {
const id = parts[1];
for (const dom of preferredDomains) {
const candidate = `${dom}.${id}`;
if (this._hass.states[candidate]) return candidate;
}
} else if (parts.length === 1) {
const id = parts[0];
for (const dom of preferredDomains) {
const candidate = `${dom}.${id}`;
if (this._hass.states[candidate]) return candidate;
}
}
return eid;
}
_update() {
if (!this._root) return;
const now = Date.now();
// Purge expired optimistic entries
if (this._optimisticStates) {
for (const [key, val] of Object.entries(this._optimisticStates)) {
if (!val || val.until <= now) delete this._optimisticStates[key];
}
}
const g = (eid) => {
if (!eid) return undefined;
const ov = this._optimisticStates?.[eid];
if (ov && ov.until > now && (eid.startsWith("switch.") || eid.startsWith("light."))) {
return ov.state;
}
return this._hass?.states?.[eid]?.state;
};
const gObj = (eid) => this._hass?.states?.[eid];
const gNum = (eid) => Number(g(eid));
const fmtState = (st) => {
if (!st) return "—";
const v = st.state;
if (v === undefined || v === null) return "—";
const s = String(v);
if (s === "unknown" || s === "unavailable") return "—";
// Prefer HA's built-in formatter to honor per-entity precision and units
if (this._hass && typeof this._hass.formatEntityState === 'function') {
try { return this._hass.formatEntityState(st); } catch (_) { }
}
// Fallback: number-aware formatting with (suggested_)display_precision when present
const unit = st.attributes?.unit_of_measurement;
const n = Number(s);
if (!Number.isNaN(n) && Number.isFinite(n)) {
const dp = (typeof st.attributes?.display_precision === 'number') ? st.attributes.display_precision
: (typeof st.attributes?.suggested_display_precision === 'number') ? st.attributes.suggested_display_precision
: (unit && /°|c|f/i.test(unit)) ? 1
: 2;
const out = n.toFixed(Math.max(0, Math.min(6, dp)));
return unit ? `${out} ${unit}` : out;
}
return unit ? `${s} ${unit}` : s;
};
const fmtWithUnit = (eid) => fmtState(gObj(eid));
const name = this._cfg.name || "3D Printer";
const status = g(this._cfg.status) ?? "unknown";
const pct = clamp(Number.isFinite(gNum(this._cfg.progress)) ? gNum(this._cfg.progress) : 0, 0, 100);
const timeLeft = gNum(this._cfg.time_left) || 0;
const nozzleStr = fmtWithUnit(this._cfg.nozzle);
const bedStr = fmtWithUnit(this._cfg.bed);
const boxStr = fmtWithUnit(this._cfg.box);
const _rawLayer = g(this._cfg.layer);
const layer = (_rawLayer && _rawLayer !== "unavailable" && _rawLayer !== "unknown" ? _rawLayer : "") + "";
const _rawTotalLayers = g(this._cfg.total_layers);
const totalLayers = (_rawTotalLayers && _rawTotalLayers !== "unavailable" && _rawTotalLayers !== "unknown" ? _rawTotalLayers : "") + "";
const resolvedLight = this._resolveEntityId(this._cfg.light, ["light", "switch"]);
const lightState = this._hass?.states?.[resolvedLight]?.state;
const resolvedPower = this._resolveEntityId(this._cfg.power, ["switch"]);
const powerState = g(resolvedPower);
const st = normStr(status);
const isPrinting = ["printing", "resuming", "pausing"].includes(st);
const isPaused = st === "paused";
const showStop = isPrinting || isPaused || st === "self-testing";
// Show Light chip only when the light entity exists in HA state and power (if configured) is not OFF
const showLight = Boolean(resolvedLight && this._hass?.states?.[resolvedLight]) && !(resolvedPower && powerState === "off");
// Show the Power button whenever configured, independent of printer status
const showPower = Boolean(this._cfg.show_power_button) && Boolean(this._cfg.power);
// Title/status
this._root.getElementById("name").textContent = name;
const proper = (!status || status === "unavailable" || status === "unknown") ? this._t("status_unknown") : (fmtState(gObj(this._cfg.status)) || status[0].toUpperCase() + status.slice(1));
const sec = (isPrinting || isPaused) ? `${pct}% ${proper}` : proper;
this._root.getElementById("secondary").textContent = sec;
// Icon & ring
const iconEl = this._root.getElementById("icon");
iconEl.setAttribute("icon", computeIcon(status));
const theme = this._cfg.theme || {};
const iconColor = theme.status_icon === "auto" ? computeColor(status) : theme.status_icon;
iconEl.style.setProperty("--icon-color", iconColor);
const ring = this._root.getElementById("ring");
ring.style.setProperty("--ring-pct", isPrinting || isPaused ? `${pct}%` : "0%");
const ringColor = theme.progress_ring === "auto" ? computeColor(status) : theme.progress_ring;
ring.style.setProperty("--ring-color", ringColor);
const chipsContainer = this._root.getElementById("chips-container");
// Define button definitions
const buttons = {
pause: {
hidden: !isPrinting,
class: "warn",
icon: this._cfg.pause_btn_icon || "mdi:pause",
title: this._t("chip_pause")
},
resume: {
hidden: !isPaused,
class: "ok",
icon: this._cfg.resume_btn_icon || "mdi:play",
title: this._t("chip_resume")
},
stop: {
hidden: !showStop,
class: "danger",
icon: this._cfg.stop_btn_icon || "mdi:stop",
title: this._t("chip_stop")
},
light: {
hidden: !showLight,
class: lightState === "on" ? "light-on" : "light-off",
icon: this._cfg.light_btn_icon || "mdi:lightbulb",
title: this._t("chip_light")
},
power: {
hidden: !showPower,
class: "unknown", // Calculated below
icon: this._cfg.power_btn_icon || "mdi:power",
title: this._t("chip_power")
},
custom: {
hidden: Boolean(this._cfg.custom_btn_hidden) || !this._cfg.custom_btn,
class: "custom",
icon: this._cfg.custom_btn_icon || "mdi:gesture-tap",
title: this._t("chip_custom")
}
};
// Calculate Custom Button Class based on state
if (!buttons.custom.hidden) {
const customEid = this._cfg.custom_btn;
const dom = customEid ? customEid.split(".")[0] : "";
const state = g(customEid);
// Only use on/off styling for stateful domains
if (["switch", "light", "input_boolean", "fan", "cover", "binary_sensor"].includes(dom)) {
const isOn = state === "on" || state === "open"; // cover uses open/closed
buttons.custom.class = isOn ? "custom-on" : "custom-off";
}
}
// Calculate Power Class Logic
if (!buttons.power.hidden) {
this._lastPowerState = this._lastPowerState || null;
if (powerState === "on" || powerState === "off") {
this._lastPowerState = powerState;
}
const isOn = powerState === "on" || (!powerState && this._lastPowerState === "on");
const isOff = powerState === "off" || (!powerState && this._lastPowerState === "off");
buttons.power.class = isOn ? "power-on" : (isOff ? "power-off" : "unknown");
}
// Reconstruct DOM only if order or visibility needs update (or simple clear/redraw for robustness)
// To avoid losing focus or animation, we could diff, but for this card, clear/redraw is fast enough
// IF we don't do it every frame. However, we're in _update().
// Let's build the HTML string for buttons based on order.
const order = Array.isArray(this._cfg.button_order) ? this._cfg.button_order : ['pause', 'resume', 'stop', 'light', 'power', 'custom'];
// Filter duplicates and ensure valid keys
const uniqueOrder = [...new Set(order)].filter(k => buttons[k]);
// Add any missing standard buttons to the end if not present (backward compat)
['pause', 'resume', 'stop', 'light', 'power'].forEach(k => {
if (!uniqueOrder.includes(k) && buttons[k]) uniqueOrder.push(k);
});
let chipsHtml = "";
uniqueOrder.forEach(key => {
const btn = buttons[key];
if (btn && !btn.hidden) {
chipsHtml += `<button class="chip ${btn.class}" id="${key}" title="${btn.title}"><ha-icon icon="${btn.icon}"></ha-icon></button>`;
}
});
if (chipsContainer.innerHTML !== chipsHtml) {
chipsContainer.innerHTML = chipsHtml;
}
// Telemetry
this._root.getElementById("nozzle").textContent = nozzleStr;
this._root.getElementById("bed").textContent = bedStr;
this._root.getElementById("box").textContent = boxStr;
this._root.getElementById("time").textContent = fmtTimeLeft(timeLeft);
this._root.getElementById("layers").textContent = `${layer || "—"}/${totalLayers || "—"}`;
// Toggle Chamber Temp visibility.
// Hide when explicitly hidden, when no chamber entity is configured, or when
// the configured entity does not exist in HA (printers without a chamber,
// e.g. Ender 3 V3 KE) so we don't render a stray thermometer icon that
// offsets the adjacent telemetry. A configured-but-unavailable entity stays
// visible and shows "—", matching the nozzle/bed pills.
const boxPill = this._root.getElementById("box-pill");
if (boxPill) {
const boxConfigured = Boolean(this._cfg.box) && Boolean(this._hass?.states?.[this._cfg.box]);
boxPill.style.display = (this._cfg.hide_box_temp || !boxConfigured) ? "none" : "";
}
this._scheduleTelemetrySizeUpdate();
}
}
const CARD_TRANSLATIONS = {
en: {
status_unknown: "Unknown",
confirm_stop: "Are you sure you want to stop the print?",
confirm_power_off: "Are you sure you want to power off the printer?",
confirm_power_off_printing: "A print is in progress. Are you sure you want to power off the printer?",
chip_pause: "Pause",
chip_resume: "Resume",
chip_stop: "Stop",
chip_light: "Light",
chip_power: "Power",
chip_custom: "Custom Action",
editor_title: "Creality Printer Card Configuration",
tab_entities: "Entities",
tab_theme: "Theme",
group_layout: "Layout & Icons",
group_action_colors: "Action Colors",
group_status_area: "Status Area",
group_telemetry: "Telemetry",
color_pause_bg: "Pause Button Background",
color_pause_icon: "Pause Button Icon",
color_resume_bg: "Resume Button Background",
color_resume_icon: "Resume Button Icon",
color_stop_bg: "Stop Button Background",
color_stop_icon: "Stop Button Icon",
color_light_on_bg: "Light On Background",
color_light_off_bg: "Light Off Background",
color_light_icon_on: "Light Button Icon (On)",
color_light_icon_off: "Light Button Icon (Off)",
color_custom_bg: "Custom Button Background",
color_custom_icon: "Custom Button Icon",
color_status_icon: "Status Icon Color",
color_progress_ring: "Progress Ring Color",
color_status_bg: "Status Background",
color_telemetry_icon: "Telemetry Icon Color",
color_telemetry_text: "Telemetry Text Color",
btn_save: "Save",
btn_reset: "Reset to Defaults",
color_picker_hint: "Click to open color picker",
label_name: "Printer Name",
label_camera: "Camera",
label_status: "Print Status Sensor",
label_progress: "Print Progress Sensor (%)",
label_time_left: "Time Left Sensor",
label_nozzle: "Nozzle Temperature Sensor",
label_bed: "Bed Temperature Sensor",
label_box: "Chamber Temperature Sensor",
label_power: "Power Switch",
label_show_power_button: "Show Power Button",
label_layer: "Current Layer Sensor",
label_total_layers: "Total Layers Sensor",
label_light: "Light Switch",
label_pause_btn: "Pause Button",
label_resume_btn: "Resume Button",
label_stop_btn: "Stop Button",
label_custom_btn: "Custom Action Entity",
label_custom_btn_icon: "Custom Button Icon",
label_custom_btn_hidden: "Hide Custom Button",
label_button_order: "Button Order (list)",
helper_name: "Display name for the printer card",
helper_camera: "Camera entity for live video feed",
helper_status: "Sensor showing current print status",
helper_progress: "Sensor showing print progress (0-100%)",
helper_time_left: "Sensor showing remaining print time (seconds)",
helper_nozzle: "Sensor showing nozzle temperature",
helper_bed: "Sensor showing bed temperature",
helper_box: "Sensor showing chamber/enclosure temperature (optional)",
helper_power: "Optional power switch entity for the printer (shows a Power button when set)",
helper_show_power_button: "Show the Power button when a power switch entity is configured",
helper_layer: "Sensor showing current print layer",
helper_total_layers: "Sensor showing total print layers",
helper_light: "Switch entity for printer light control",
helper_pause_btn: "Button entity to pause printing",
helper_resume_btn: "Button entity to resume printing",
helper_stop_btn: "Button entity to stop printing",
helper_custom_btn: "Any entity to trigger (Button, Script, Switch, etc.)",
helper_custom_btn_icon: "Icon for the custom button",
helper_custom_btn_hidden: "Hide the custom button",
helper_button_order: "List of buttons to show in order (pause, resume, stop, light, power, custom)",
schema_button_order: "Button Order (pause, resume, stop, light, power, custom)",
schema_hide_custom: "Hide Custom Button",
schema_hide_box_temp: "Hide Chamber Temperature",
schema_pause_icon: "Pause Icon Override",
schema_resume_icon: "Resume Icon Override",
schema_stop_icon: "Stop Icon Override",
schema_light_icon: "Light Icon Override",
schema_power_icon: "Power Icon Override",
schema_custom_icon: "Custom Button Icon",
editor_error_title: "Editor Error",
editor_error_msg: "There was an error loading the visual editor. You can still edit your configuration using YAML.",
editor_error_prefix: "Error:",
},
};
customElements.define(CARD_TAG, KPrinterCard);
/* Interactive theme editor */
class KPrinterCardEditor extends HTMLElement {
// i18n helpers -------------------------------------------------------
_resolveLanguage() {
return _resolveLang(this._hass);
}
_t(key) {
return _translate(this._hass, "printer_card", CARD_TRANSLATIONS, key);
}
// ---------------------------------------------------------------------
set hass(hass) {
this._hass = hass;
if (this._entitiesForm) this._entitiesForm.hass = hass;
_requestI18n(this, hass, () => { if (this._root) this._render(); });
}
setConfig(config) {
const defaultConfig = KPrinterCard.getStubConfig();
this._cfg = { ...defaultConfig, ...(config || {}) };
// Always ensure theme is properly initialized
this._cfg.theme = { ...defaultConfig.theme, ...(this._cfg.theme || {}) };
if (this._root) {
this._render();
}
}
connectedCallback() { if (!this._root) { this._root = this.attachShadow({ mode: "open" }); this._render(); } }
_render() {
if (!this._root || !this._cfg) return;
try {
const style = `
:host { display: block; }
.editor-container { padding: 16px; max-width: 1200px; margin: 0 auto; }
.tabs { display: flex; border-bottom: 1px solid var(--divider-color); margin-bottom: 16px; }
.tab { padding: 8px 16px; cursor: pointer; border-bottom: 2px solid transparent; }
.tab.active { border-bottom-color: var(--primary-color); color: var(--primary-color); }
.tab-content { display: none; }
.tab-content.active { display: block; }
.theme-editor { display: block; }
.theme-controls {
display: flex;
flex-direction: column;
gap: 16px;
}
.control-group {
background: var(--card-background-color);
border: 1px solid var(--divider-color);
border-radius: 8px;
padding: 12px;
}
.group-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 8px;
color: var(--primary-text-color);
}
.clickable-element {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
border: 1px solid transparent;
border-radius: 4px;
cursor: pointer;
margin: 4px 0;
transition: all 0.2s;
}
.clickable-element:hover {
border-color: var(--primary-color);
background: rgba(var(--rgb-primary-color), 0.1);
}
.element-preview {
width: 24px;
height: 24px;
border-radius: 4px;
border: 1px solid var(--divider-color);
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
}
.element-label {
font-size: 12px;
color: var(--primary-text-color);
flex: 1;
}
.color-picker-inline {
background: var(--card-background-color, #ffffff);
border: 1px solid var(--divider-color, #ccc);
border-radius: 8px;
padding: 12px;
margin: 8px 0;
display: none;
position: relative;
z-index: 10;
}
.color-picker-inline.active {
display: block;
}
.reset-btn {
background: var(--error-color);
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
margin-top: 16px;
width: 100%;
}
.reset-btn:hover {
opacity: 0.8;
}
.input-row { margin-bottom: 12px; }
.input-label { display: block; font-size: 12px; font-weight: 500; margin-bottom: 4px; color: var(--primary-text-color); }
.text-input { width: 100%; padding: 8px; border: 1px solid var(--divider-color); border-radius: 4px; background: var(--card-background-color); color: var(--primary-text-color); box-sizing: border-box;}
.input-helper { font-size: 11px; color: var(--secondary-text-color); margin-top: 2px; }
.checkbox-row { display: flex; align-items: center; justify-content: space-between; }
`;
this._root.innerHTML = `
<style>${style}</style>
<div class="editor-container">
<h2 style="margin: 0 0 16px 0; font-size: 18px; color: var(--primary-text-color);">${this._t("editor_title")}</h2>
<div class="tabs">
<div class="tab active" data-tab="entities">${this._t("tab_entities")}</div>
<div class="tab" data-tab="theme">${this._t("tab_theme")}</div>
</div>
<div class="tab-content active" id="entities-tab">
<ha-form id="entities-form"></ha-form>
</div>
<div class="tab-content" id="theme-tab">
<div class="theme-editor">
<div class="theme-controls">
<div class="control-group">
<div class="group-title">${this._t("group_layout")}</div>
<ha-form id="theme-settings-form"></ha-form>
</div>
<div class="control-group">
<div class="group-title">${this._t("group_action_colors")}</div>
<div class="clickable-element" data-theme="pause_bg" data-label="${this._t("color_pause_bg")}">
<div class="element-preview" style="background: ${this._cfg.theme.pause_bg}"></div>
<div class="element-label">${this._t("color_pause_bg")}</div>
</div>
<div class="color-picker-inline" id="color-picker-pause_bg">
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;">
<div class="color-preview" style="width: 30px; height: 30px; border: 2px solid #ccc; border-radius: 4px; background: ${rgbaToHex(this._cfg.theme.pause_bg)}; cursor: pointer;" title="${this._t("color_picker_hint")}"></div>
<input type="text" class="color-text" value="${rgbaToHex(this._cfg.theme.pause_bg)}" placeholder="#000000" style="flex: 1; padding: 6px; border: 1px solid #ccc; border-radius: 4px; font-family: monospace; font-size: 12px;">
<button class="save-color-btn" data-theme="pause_bg" style="padding: 6px 12px; background: var(--primary-color); color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px;">${this._t("btn_save")}</button>
</div>
</div>
<div class="clickable-element" data-theme="pause_icon" data-label="${this._t("color_pause_icon")}">
<div class="element-preview" style="background: ${this._cfg.theme.pause_icon}; color: ${this._cfg.theme.pause_icon}">⏸</div>
<div class="element-label">${this._t("color_pause_icon")}</div>
</div>
<div class="color-picker-inline" id="color-picker-pause_icon">
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;">
<div class="color-preview" style="width: 30px; height: 30px; border: 2px solid #ccc; border-radius: 4px; background: ${rgbaToHex(this._cfg.theme.pause_icon)}; cursor: pointer;" title="${this._t("color_picker_hint")}"></div>
<input type="text" class="color-text" value="${rgbaToHex(this._cfg.theme.pause_icon)}" placeholder="#000000" style="flex: 1; padding: 6px; border: 1px solid #ccc; border-radius: 4px; font-family: monospace; font-size: 12px;">
<button class="save-color-btn" data-theme="pause_icon" style="padding: 6px 12px; background: var(--primary-color); color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px;">${this._t("btn_save")}</button>
</div>
</div>
<div class="clickable-element" data-theme="resume_bg" data-label="${this._t("color_resume_bg")}">
<div class="element-preview" style="background: ${this._cfg.theme.resume_bg}"></div>
<div class="element-label">${this._t("color_resume_bg")}</div>
</div>
<div class="color-picker-inline" id="color-picker-resume_bg">
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;">
<div class="color-preview" style="width: 30px; height: 30px; border: 2px solid #ccc; border-radius: 4px; background: ${rgbaToHex(this._cfg.theme.resume_bg)}; cursor: pointer;" title="${this._t("color_picker_hint")}"></div>
<input type="text" class="color-text" value="${rgbaToHex(this._cfg.theme.resume_bg)}" placeholder="#000000" style="flex: 1; padding: 6px; border: 1px solid #ccc; border-radius: 4px; font-family: monospace; font-size: 12px;">
<button class="save-color-btn" data-theme="resume_bg" style="padding: 6px 12px; background: var(--primary-color); color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px;">${this._t("btn_save")}</button>
</div>
</div>
<div class="clickable-element" data-theme="resume_icon" data-label="${this._t("color_resume_icon")}">
<div class="element-preview" style="background: ${this._cfg.theme.resume_icon}; color: ${this._cfg.theme.resume_icon}">▶</div>
<div class="element-label">${this._t("color_resume_icon")}</div>
</div>
<div class="color-picker-inline" id="color-picker-resume_icon">
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;">
<div class="color-preview" style="width: 30px; height: 30px; border: 2px solid #ccc; border-radius: 4px; background: ${rgbaToHex(this._cfg.theme.resume_icon)}; cursor: pointer;" title="${this._t("color_picker_hint")}"></div>
<input type="text" class="color-text" value="${rgbaToHex(this._cfg.theme.resume_icon)}" placeholder="#000000" style="flex: 1; padding: 6px; border: 1px solid #ccc; border-radius: 4px; font-family: monospace; font-size: 12px;">
<button class="save-color-btn" data-theme="resume_icon" style="padding: 6px 12px; background: var(--primary-color); color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px;">${this._t("btn_save")}</button>
</div>
</div>
<div class="clickable-element" data-theme="stop_bg" data-label="${this._t("color_stop_bg")}">
<div class="element-preview" style="background: ${this._cfg.theme.stop_bg}"></div>
<div class="element-label">${this._t("color_stop_bg")}</div>
</div>
<div class="color-picker-inline" id="color-picker-stop_bg">
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;">
<div class="color-preview" style="width: 30px; height: 30px; border: 2px solid #ccc; border-radius: 4px; background: ${rgbaToHex(this._cfg.theme.stop_bg)}; cursor: pointer;" title="${this._t("color_picker_hint")}"></div>
<input type="text" class="color-text" value="${rgbaToHex(this._cfg.theme.stop_bg)}" placeholder="#000000" style="flex: 1; padding: 6px; border: 1px solid #ccc; border-radius: 4px; font-family: monospace; font-size: 12px;">
<button class="save-color-btn" data-theme="stop_bg" style="padding: 6px 12px; background: var(--primary-color); color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px;">${this._t("btn_save")}</button>
</div>
</div>
<div class="clickable-element" data-theme="stop_icon" data-label="${this._t("color_stop_icon")}">
<div class="element-preview" style="background: ${this._cfg.theme.stop_icon}; color: ${this._cfg.theme.stop_icon}">⏹</div>
<div class="element-label">${this._t("color_stop_icon")}</div>
</div>
<div class="color-picker-inline" id="color-picker-stop_icon">
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;">
<div class="color-preview" style="width: 30px; height: 30px; border: 2px solid #ccc; border-radius: 4px; background: ${rgbaToHex(this._cfg.theme.stop_icon)}; cursor: pointer;" title="${this._t("color_picker_hint")}"></div>
<input type="text" class="color-text" value="${rgbaToHex(this._cfg.theme.stop_icon)}" placeholder="#000000" style="flex: 1; padding: 6px; border: 1px solid #ccc; border-radius: 4px; font-family: monospace; font-size: 12px;">
<button class="save-color-btn" data-theme="stop_icon" style="padding: 6px 12px; background: var(--primary-color); color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px;">${this._t("btn_save")}</button>
</div>
</div>
<div class="clickable-element" data-theme="light_on_bg" data-label="${this._t("color_light_on_bg")}">
<div class="element-preview" style="background: ${this._cfg.theme.light_on_bg}"></div>
<div class="element-label">${this._t("color_light_on_bg")}</div>
</div>
<div class="color-picker-inline" id="color-picker-light_on_bg">
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;">
<div class="color-preview" style="width: 30px; height: 30px; border: 2px solid #ccc; border-radius: 4px; background: ${rgbaToHex(this._cfg.theme.light_on_bg)}; cursor: pointer;" title="${this._t("color_picker_hint")}"></div>
<input type="text" class="color-text" value="${rgbaToHex(this._cfg.theme.light_on_bg)}" placeholder="#000000" style="flex: 1; padding: 6px; border: 1px solid #ccc; border-radius: 4px; font-family: monospace; font-size: 12px;">
<button class="save-color-btn" data-theme="light_on_bg" style="padding: 6px 12px; background: var(--primary-color); color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px;">${this._t("btn_save")}</button>
</div>
</div>
<div class="clickable-element" data-theme="light_off_bg" data-label="${this._t("color_light_off_bg")}">
<div class="element-preview" style="background: ${this._cfg.theme.light_off_bg}"></div>
<div class="element-label">${this._t("color_light_off_bg")}</div>
</div>
<div class="color-picker-inline" id="color-picker-light_off_bg">
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;">
<div class="color-preview" style="width: 30px; height: 30px; border: 2px solid #ccc; border-radius: 4px; background: ${rgbaToHex(this._cfg.theme.light_off_bg)}; cursor: pointer;" title="${this._t("color_picker_hint")}"></div>
<input type="text" class="color-text" value="${rgbaToHex(this._cfg.theme.light_off_bg)}" placeholder="#000000" style="flex: 1; padding: 6px; border: 1px solid #ccc; border-radius: 4px; font-family: monospace; font-size: 12px;">
<button class="save-color-btn" data-theme="light_off_bg" style="padding: 6px 12px; background: var(--primary-color); color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px;">${this._t("btn_save")}</button>
</div>
</div>
<div class="clickable-element" data-theme="light_icon_on" data-label="${this._t("color_light_icon_on")}">
<div class="element-preview" style="background: ${this._cfg.theme.light_icon_on}; color: ${this._cfg.theme.light_icon_on}">💡</div>
<div class="element-label">${this._t("color_light_icon_on")}</div>
</div>
<div class="color-picker-inline" id="color-picker-light_icon_on">
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;">
<div class="color-preview" style="width: 30px; height: 30px; border: 2px solid #ccc; border-radius: 4px; background: ${rgbaToHex(this._cfg.theme.light_icon_on)}; cursor: pointer;" title="${this._t("color_picker_hint")}"></div>
<input type="text" class="color-text" value="${rgbaToHex(this._cfg.theme.light_icon_on)}" placeholder="#000000" style="flex: 1; padding: 6px; border: 1px solid #ccc; border-radius: 4px; font-family: monospace; font-size: 12px;">
<button class="save-color-btn" data-theme="light_icon_on" style="padding: 6px 12px; background: var(--primary-color); color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px;">${this._t("btn_save")}</button>
</div>
</div>
<div class="clickable-element" data-theme="light_icon_off" data-label="${this._t("color_light_icon_off")}">
<div class="element-preview" style="background: ${this._cfg.theme.light_icon_off}; color: ${this._cfg.theme.light_icon_off}">💡</div>
<div class="element-label">${this._t("color_light_icon_off")}</div>
</div>
<div class="color-picker-inline" id="color-picker-light_icon_off">
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;">
<div class="color-preview" style="width: 30px; height: 30px; border: 2px solid #ccc; border-radius: 4px; background: ${rgbaToHex(this._cfg.theme.light_icon_off)}; cursor: pointer;" title="${this._t("color_picker_hint")}"></div>
<input type="text" class="color-text" value="${rgbaToHex(this._cfg.theme.light_icon_off)}" placeholder="#000000" style="flex: 1; padding: 6px; border: 1px solid #ccc; border-radius: 4px; font-family: monospace; font-size: 12px;">
<button class="save-color-btn" data-theme="light_icon_off" style="padding: 6px 12px; background: var(--primary-color); color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px;">${this._t("btn_save")}</button>
</div>
</div>
<div class="clickable-element" data-theme="custom_bg" data-label="${this._t("color_custom_bg")}">
<div class="element-preview" style="background: ${this._cfg.theme.custom_bg}"></div>
<div class="element-label">${this._t("color_custom_bg")}</div>
</div>
<div class="color-picker-inline" id="color-picker-custom_bg">
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;">
<div class="color-preview" style="width: 30px; height: 30px; border: 2px solid #ccc; border-radius: 4px; background: ${rgbaToHex(this._cfg.theme.custom_bg)}; cursor: pointer;" title="${this._t("color_picker_hint")}"></div>
<input type="text" class="color-text" value="${rgbaToHex(this._cfg.theme.custom_bg)}" placeholder="#000000" style="flex: 1; padding: 6px; border: 1px solid #ccc; border-radius: 4px; font-family: monospace; font-size: 12px;">
<button class="save-color-btn" data-theme="custom_bg" style="padding: 6px 12px; background: var(--primary-color); color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px;">${this._t("btn_save")}</button>
</div>
</div>
<div class="clickable-element" data-theme="custom_icon" data-label="${this._t("color_custom_icon")}">
<div class="element-preview" style="background: ${this._cfg.theme.custom_icon}; color: ${this._cfg.theme.custom_icon}">★</div>
<div class="element-label">${this._t("color_custom_icon")}</div>
</div>
<div class="color-picker-inline" id="color-picker-custom_icon">
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;">
<div class="color-preview" style="width: 30px; height: 30px; border: 2px solid #ccc; border-radius: 4px; background: ${rgbaToHex(this._cfg.theme.custom_icon)}; cursor: pointer;" title="${this._t("color_picker_hint")}"></div>
<input type="text" class="color-text" value="${rgbaToHex(this._cfg.theme.custom_icon)}" placeholder="#000000" style="flex: 1; padding: 6px; border: 1px solid #ccc; border-radius: 4px; font-family: monospace; font-size: 12px;">
<button class="save-color-btn" data-theme="custom_icon" style="padding: 6px 12px; background: var(--primary-color); color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px;">${this._t("btn_save")}</button>
</div>
</div>
</div>
<div class="control-group">
<div class="group-title">${this._t("group_status_area")}</div>
<div class="clickable-element" data-theme="status_icon" data-label="${this._t("color_status_icon")}">
<div class="element-preview" style="background: ${this._cfg.theme.status_icon === 'auto' ? 'var(--primary-color)' : this._cfg.theme.status_icon}">🖨</div>
<div class="element-label">${this._t("color_status_icon")}</div>
</div>
<div class="color-picker-inline" id="color-picker-status_icon">
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;">
<div class="color-preview" style="width: 30px; height: 30px; border: 2px solid #ccc; border-radius: 4px; background: ${this._cfg.theme.status_icon === 'auto' ? '#000000' : rgbaToHex(this._cfg.theme.status_icon)}; cursor: pointer;" title="${this._t("color_picker_hint")}"></div>
<input type="text" class="color-text" value="${this._cfg.theme.status_icon === 'auto' ? 'auto' : rgbaToHex(this._cfg.theme.status_icon)}" placeholder="#000000" style="flex: 1; padding: 6px; border: 1px solid #ccc; border-radius: 4px; font-family: monospace; font-size: 12px;">
<button class="save-color-btn" data-theme="status_icon" style="padding: 6px 12px; background: var(--primary-color); color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px;">${this._t("btn_save")}</button>
</div>
</div>
<div class="clickable-element" data-theme="progress_ring" data-label="${this._t("color_progress_ring")}">
<div class="element-preview" style="background: ${this._cfg.theme.progress_ring === 'auto' ? 'var(--primary-color)' : this._cfg.theme.progress_ring}">⭕</div>
<div class="element-label">${this._t("color_progress_ring")}</div>
</div>
<div class="color-picker-inline" id="color-picker-progress_ring">
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;">
<div class="color-preview" style="width: 30px; height: 30px; border: 2px solid #ccc; border-radius: 4px; background: ${this._cfg.theme.progress_ring === 'auto' ? '#000000' : rgbaToHex(this._cfg.theme.progress_ring)}; cursor: pointer;" title="${this._t("color_picker_hint")}"></div>
<input type="text" class="color-text" value="${this._cfg.theme.progress_ring === 'auto' ? 'auto' : rgbaToHex(this._cfg.theme.progress_ring)}" placeholder="#000000" style="flex: 1; padding: 6px; border: 1px solid #ccc; border-radius: 4px; font-family: monospace; font-size: 12px;">
<button class="save-color-btn" data-theme="progress_ring" style="padding: 6px 12px; background: var(--primary-color); color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px;">${this._t("btn_save")}</button>
</div>
</div>
<div class="clickable-element" data-theme="status_bg" data-label="${this._t("color_status_bg")}">
<div class="element-preview" style="background: ${this._cfg.theme.status_bg}">🎯</div>
<div class="element-label">${this._t("color_status_bg")}</div>
</div>
<div class="color-picker-inline" id="color-picker-status_bg">
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;">
<div class="color-preview" style="width: 30px; height: 30px; border: 2px solid #ccc; border-radius: 4px; background: ${this._cfg.theme.status_bg === 'auto' ? 'var(--card-background-color)' : rgbaToHex(this._cfg.theme.status_bg)}; cursor: pointer;" title="${this._t("color_picker_hint")}"></div>
<input type="text" class="color-text" value="${this._cfg.theme.status_bg === 'auto' ? 'auto' : rgbaToHex(this._cfg.theme.status_bg)}" placeholder="auto or #000000" style="flex: 1; padding: 6px; border: 1px solid #ccc; border-radius: 4px; font-family: monospace; font-size: 12px;">
<button class="save-color-btn" data-theme="status_bg" style="padding: 6px 12px; background: var(--primary-color); color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px;">${this._t("btn_save")}</button>
</div>
</div>
</div>
<div class="control-group">
<div class="group-title">${this._t("group_telemetry")}</div>
<div class="clickable-element" data-theme="telemetry_icon" data-label="${this._t("color_telemetry_icon")}">
<div class="element-preview" style="background: ${this._cfg.theme.telemetry_icon}">🌡</div>
<div class="element-label">${this._t("color_telemetry_icon")}</div>
</div>
<div class="color-picker-inline" id="color-picker-telemetry_icon">
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;">
<div class="color-preview" style="width: 30px; height: 30px; border: 2px solid #ccc; border-radius: 4px; background: ${this._cfg.theme.telemetry_icon === 'auto' ? 'var(--secondary-text-color)' : rgbaToHex(this._cfg.theme.telemetry_icon)}; cursor: pointer;" title="${this._t("color_picker_hint")}"></div>
<input type="text" class="color-text" value="${this._cfg.theme.telemetry_icon === 'auto' ? 'auto' : rgbaToHex(this._cfg.theme.telemetry_icon)}" placeholder="auto or #000000" style="flex: 1; padding: 6px; border: 1px solid #ccc; border-radius: 4px; font-family: monospace; font-size: 12px;">
<button class="save-color-btn" data-theme="telemetry_icon" style="padding: 6px 12px; background: var(--primary-color); color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px;">${this._t("btn_save")}</button>
</div>
</div>
<div class="clickable-element" data-theme="telemetry_text" data-label="${this._t("color_telemetry_text")}">
<div class="element-preview" style="background: ${this._cfg.theme.telemetry_text}">📝</div>
<div class="element-label">${this._t("color_telemetry_text")}</div>
</div>
<div class="color-picker-inline" id="color-picker-telemetry_text">
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;">
<div class="color-preview" style="width: 30px; height: 30px; border: 2px solid #ccc; border-radius: 4px; background: ${this._cfg.theme.telemetry_text === 'auto' ? 'var(--primary-text-color)' : rgbaToHex(this._cfg.theme.telemetry_text)}; cursor: pointer;" title="${this._t("color_picker_hint")}"></div>
<input type="text" class="color-text" value="${this._cfg.theme.telemetry_text === 'auto' ? 'auto' : rgbaToHex(this._cfg.theme.telemetry_text)}" placeholder="auto or #000000" style="flex: 1; padding: 6px; border: 1px solid #ccc; border-radius: 4px; font-family: monospace; font-size: 12px;">
<button class="save-color-btn" data-theme="telemetry_text" style="padding: 6px 12px; background: var(--primary-color); color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px;">${this._t("btn_save")}</button>
</div>
</div>
</div>
<button class="reset-btn" id="reset-theme">${this._t("btn_reset")}</button>
</div>
</div>
</div>
</div>
`;
this._setupTabs();
this._setupEntitiesForm();
this._setupThemeEditor();
} catch (error) {
console.error('Error rendering K-Printer Card Editor:', error);
this._root.innerHTML = `
<div style="padding: 16px; color: var(--error-color);">
<h3>Editor Error</h3>
<p>${this._t("editor_error_msg")}</p>
<p>${this._t("editor_error_prefix")} ${error.message}</p>
</div>
`;
}
}
_setupTabs() {
const tabs = this._root.querySelectorAll('.tab');
const contents = this._root.querySelectorAll('.tab-content');
tabs.forEach(tab => {
tab.addEventListener('click', () => {
tabs.forEach(t => t.classList.remove('active'));
contents.forEach(c => c.classList.remove('active'));
tab.classList.add('active');
const tabId = tab.dataset.tab + '-tab';
this._root.getElementById(tabId).classList.add('active');
});
});
}
_setupEntitiesForm() {
this._entitiesForm = this._root.getElementById('entities-form');
this._entitiesForm.hass = this._hass;
// Helper text mapping for form fields
const helperText = {
"name": "Display name for the printer card",
"camera": "Camera entity for live video feed",
"status": "Sensor showing current print status",
"progress": "Sensor showing print progress (0-100%)",
"time_left": "Sensor showing remaining print time (seconds)",
"nozzle": "Sensor showing nozzle temperature",
"bed": "Sensor showing bed temperature",
"box": "Sensor showing chamber/enclosure temperature (optional)",
"power": "Optional power switch entity for the printer (shows a Power button when set)",
"show_power_button": "Show the Power button when a power switch entity is configured",
"layer": "Sensor showing current print layer",
"total_layers": "Sensor showing total print layers",
"light": "Switch entity for printer light control",
"pause_btn": "Button entity to pause printing",
"resume_btn": "Button entity to resume printing",
"stop_btn": "Button entity to stop printing",
"custom_btn": "Any entity to trigger (Button, Script, Switch, etc.)",
"custom_btn_icon": "Icon for the custom button",
"custom_btn_hidden": "Hide the custom button",
"button_order": "List of buttons to show in order (pause, resume, stop, light, power, custom)"
};
this._entitiesForm.schema = [
{ name: "name", selector: { text: {} } },
{ name: "camera", selector: { entity: { domain: "camera" } } },
{ name: "status", selector: { entity: { domain: "sensor" } } },
{ name: "progress", selector: { entity: { domain: "sensor" } } },
{ name: "time_left", selector: { entity: { domain: "sensor" } } },
{ name: "nozzle", selector: { entity: { domain: "sensor" } } },
{ name: "bed", selector: { entity: { domain: "sensor" } } },
{ name: "box", selector: { entity: { domain: "sensor" } } },
{ name: "power", selector: { entity: { domain: ["switch", "input_boolean"] } } },
{ name: "show_power_button", selector: { boolean: {} } },
{ name: "layer", selector: { entity: { domain: "sensor" } } },
{ name: "total_layers", selector: { entity: { domain: "sensor" } } },
{ name: "light", selector: { entity: { domain: ["switch", "light"] } } },
{ name: "pause_btn", selector: { entity: { domain: "button" } } },
{ name: "resume_btn", selector: { entity: { domain: "button" } } },
{ name: "stop_btn", selector: { entity: { domain: "button" } } },
{ name: "custom_btn", selector: { entity: {} } },
];
// Label text mapping for form fields
const labelText = {
"name": this._t("label_name"),
"camera": this._t("label_camera"),
"status": this._t("label_status"),
"progress": this._t("label_progress"),
"time_left": this._t("label_time_left"),
"nozzle": this._t("label_nozzle"),
"bed": this._t("label_bed"),
"box": this._t("label_box"),
"power": this._t("label_power"),
"show_power_button": this._t("label_show_power_button"),
"layer": this._t("label_layer"),
"total_layers": this._t("label_total_layers"),
"light": this._t("label_light"),
"pause_btn": this._t("label_pause_btn"),
"resume_btn": this._t("label_resume_btn"),
"stop_btn": this._t("label_stop_btn"),
"custom_btn": this._t("label_custom_btn"),
"custom_btn_icon": this._t("label_custom_btn_icon"),
"custom_btn_hidden": this._t("label_custom_btn_hidden"),
"button_order": this._t("label_button_order"),
};
// Add label computation using computeLabel if supported
if (this._entitiesForm.computeLabel) {
const originalComputeLabel = this._entitiesForm.computeLabel.bind(this._entitiesForm);
this._entitiesForm.computeLabel = (schema) => {
return labelText[schema.name] || originalComputeLabel(schema);
};
}
// Add helper text using computeHelper if supported
if (this._entitiesForm.computeHelper) {
const originalComputeHelper = this._entitiesForm.computeHelper.bind(this._entitiesForm);
this._entitiesForm.computeHelper = (schema) => {
return helperText[schema.name] || originalComputeHelper(schema);
};
}
this._entitiesForm.data = this._cfg;
this._entitiesForm.addEventListener("value-changed", (ev) => {
const val = ev.detail?.value || {};
this._cfg = { ...this._cfg, ...val };
this._dispatchConfigChange();
});
}
_setupThemeEditor() {
// Setup generic config form (Layout & Icons)
this._themeSettingsForm = this._root.getElementById('theme-settings-form');
this._themeSettingsForm.hass = this._hass;
const themeSettingsSchema = [
{ name: "button_order", selector: { text: {} }, label: this._t("schema_button_order") },
{ name: "custom_btn_hidden", selector: { boolean: {} }, label: this._t("schema_hide_custom") },
{ name: "hide_box_temp", selector: { boolean: {} }, label: this._t("schema_hide_box_temp") },
{ name: "pause_btn_icon", selector: { icon: {} }, label: this._t("schema_pause_icon") },
{ name: "resume_btn_icon", selector: { icon: {} }, label: this._t("schema_resume_icon") },
{ name: "stop_btn_icon", selector: { icon: {} }, label: this._t("schema_stop_icon") },
{ name: "light_btn_icon", selector: { icon: {} }, label: this._t("schema_light_icon") },
{ name: "power_btn_icon", selector: { icon: {} }, label: this._t("schema_power_icon") },
{ name: "custom_btn_icon", selector: { icon: {} }, label: this._t("schema_custom_icon") },
];
// Polyfill label if needed or rely on 'label' property in schema if HA supports it (HA Form supports 'label' in schema usually? No, it uses computeLabel)
// Let's use computeLabel for this form too.
this._themeSettingsForm.schema = themeSettingsSchema;
// Prepare data for the form. button_order needs to be stringified if it's an array, or handled as list.
// Text input expects string.
const prepareFormData = (cfg) => {
return {
...cfg,
button_order: Array.isArray(cfg.button_order) ? cfg.button_order.join(', ') : cfg.button_order
};
};
this._themeSettingsForm.data = prepareFormData(this._cfg);
this._themeSettingsForm.addEventListener("value-changed", (ev) => {
const val = ev.detail?.value || {};
// Post-process button_order back to array
if (val.button_order && typeof val.button_order === 'string') {
val.button_order = val.button_order.split(',').map(s => s.trim()).filter(s => s);
}
this._cfg = { ...this._cfg, ...val };
this._dispatchConfigChange();
});
// Setup clickable elements to toggle inline color pickers
const clickableElements = this._root.querySelectorAll('.clickable-element');
clickableElements.forEach(element => {
element.addEventListener('click', () => {
const themeKey = element.dataset.theme;
this._toggleColorPicker(themeKey);
});
});
// Setup color picker interactions
this._setupColorPickerInteractions();
// Setup reset button
this._root.getElementById('reset-theme').addEventListener('click', () => {
const defaultConfig = KPrinterCard.getStubConfig();
// Reset Theme
this._cfg.theme = { ...defaultConfig.theme };
// Reset Layout & Icons
['button_order', 'custom_btn_hidden', 'pause_btn_icon', 'resume_btn_icon', 'stop_btn_icon', 'light_btn_icon', 'power_btn_icon', 'custom_btn_icon'].forEach(k => {
this._cfg[k] = defaultConfig[k];
});
// Clear saved theme from storage
const cardId = generateCardId(this._cfg);
try {
const themes = JSON.parse(localStorage.getItem(THEME_STORAGE_KEY) || "{}");
delete themes[cardId];
localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify(themes));
} catch (e) {
console.warn("Failed to clear theme from localStorage:", e);
}
this._updateThemeControls();
// Update config inputs as well
if (this._themeSettingsForm) {
this._themeSettingsForm.data = prepareFormData(this._cfg);
}
this._dispatchConfigChange();
});
}
_toggleColorPicker(themeKey) {
const picker = this._root.getElementById(`color-picker-${themeKey}`);
if (!picker) return;
// If this picker is already active, close it
if (picker.classList.contains('active')) {
picker.classList.remove('active');
return;
}
// Hide all other color pickers
const allPickers = this._root.querySelectorAll('.color-picker-inline');
allPickers.forEach(p => {
p.classList.remove('active');
});
// Show the clicked color picker
picker.classList.add('active');
}
_setupColorPickerInteractions() {
// Setup color preview clicks to open native color picker
const colorPreviews = this._root.querySelectorAll('.color-preview');
colorPreviews.forEach(preview => {
preview.addEventListener('click', () => {
const picker = preview.closest('.color-picker-inline');
if (picker) {
const textInput = picker.querySelector('.color-text');
if (textInput) {
// Create a visible color input that stays in the editor
const input = document.createElement('input');
input.type = 'color';
input.value = textInput.value === 'auto' ? '#000000' : textInput.value;
// Style the input to be visible and positioned within the picker
input.style.position = 'absolute';
input.style.left = '0';
input.style.top = '0';
input.style.width = '100%';
input.style.height = '100%';
input.style.opacity = '0';
input.style.cursor = 'pointer';
input.style.zIndex = '10';
// Add the input to the picker container
picker.style.position = 'relative';
picker.appendChild(input);
// Focus and click the input
input.focus();
input.click();
// Handle color change
input.addEventListener('change', () => {
const newColor = input.value;
textInput.value = newColor;
preview.style.background = newColor;
// Remove the input after color selection
if (picker.contains(input)) {
picker.removeChild(input);
}
});
// Handle escape key
const handleKeyDown = (e) => {
if (e.key === 'Escape') {
if (picker.contains(input)) {
picker.removeChild(input);
}
document.removeEventListener('keydown', handleKeyDown);
}
};
document.addEventListener('keydown', handleKeyDown);
// Handle clicks outside the picker
const handleClickOutside = (e) => {
if (!picker.contains(e.target)) {
if (picker.contains(input)) {
picker.removeChild(input);
}
document.removeEventListener('click', handleClickOutside);
}
};
// Add click outside handler after a delay
setTimeout(() => {
document.addEventListener('click', handleClickOutside);
}, 100);
}
}
});
});
// Setup text input changes
const colorTexts = this._root.querySelectorAll('.color-text');
colorTexts.forEach(textInput => {
textInput.addEventListener('input', () => {
const value = textInput.value;
if (/^#[0-9A-Fa-f]{6}$/.test(value)) {
const picker = textInput.closest('.color-picker-inline');
if (picker) {
const preview = picker.querySelector('.color-preview');
if (preview) {
preview.style.background = value;
}
}
}
});
});
// Setup save buttons
const saveButtons = this._root.querySelectorAll('.save-color-btn');
saveButtons.forEach(button => {
button.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const themeKey = button.dataset.theme;
const picker = button.closest('.color-picker-inline');
if (picker) {
const textInput = picker.querySelector('.color-text');
if (textInput) {
let newValue;
const inputValue = textInput.value;
// Handle special cases for auto-supported fields
if ((themeKey === 'status_icon' || themeKey === 'progress_ring' ||
themeKey === 'status_bg' || themeKey === 'telemetry_icon' ||
themeKey === 'telemetry_text') && inputValue === 'auto') {
newValue = 'auto';
} else if (/^#[0-9A-Fa-f]{6}$/.test(inputValue)) {
newValue = hexToRgba(inputValue, 0.9);
} else {
// Keep existing value if input is invalid
newValue = this._cfg.theme[themeKey];
}
this._cfg.theme = { ...this._cfg.theme, [themeKey]: newValue };
// Save to storage
const cardId = generateCardId(this._cfg);
saveThemeToStorage(cardId, this._cfg.theme);
this._updateThemeControls();
// Hide the color picker first
picker.classList.remove('active');
// Dispatch config change after a small delay to prevent tab switching
setTimeout(() => {
this._dispatchConfigChange();
}, 100);
}
}
});
});
}
_updateThemeControls() {
// Update all preview elements with new colors
const clickableElements = this._root.querySelectorAll('.clickable-element');
clickableElements.forEach(element => {
const themeKey = element.dataset.theme;
const preview = element.querySelector('.element-preview');
const currentValue = this._cfg.theme[themeKey] || '';
if (themeKey === 'status_icon' || themeKey === 'progress_ring') {
preview.style.background = currentValue === 'auto' ? 'var(--primary-color)' : currentValue;
} else {
preview.style.background = currentValue;
}
if (['pause_icon', 'resume_icon', 'stop_icon', 'light_icon_on', 'light_icon_off', 'custom_icon'].includes(themeKey)) {
preview.style.color = currentValue;
}
});
// Update color picker previews and text inputs
const colorPickers = this._root.querySelectorAll('.color-picker-inline');
colorPickers.forEach(picker => {
const themeKey = picker.id.replace('color-picker-', '');
const themeValue = this._cfg.theme[themeKey];
const preview = picker.querySelector('.color-preview');
const textInput = picker.querySelector('.color-text');
if (preview && textInput) {
if (themeKey === 'status_icon' || themeKey === 'progress_ring' ||
themeKey === 'status_bg' || themeKey === 'telemetry_icon' ||
themeKey === 'telemetry_text') {
if (themeValue === 'auto') {
// Show appropriate theme color for auto values
if (themeKey === 'status_bg') {
preview.style.background = 'var(--card-background-color)';
} else if (themeKey === 'telemetry_icon') {
preview.style.background = 'var(--secondary-text-color)';
} else if (themeKey === 'telemetry_text') {
preview.style.background = 'var(--primary-text-color)';
} else {
preview.style.background = '#000000';
}
textInput.value = 'auto';
} else {
preview.style.background = rgbaToHex(themeValue);
textInput.value = rgbaToHex(themeValue);
}
} else {
preview.style.background = rgbaToHex(themeValue);
textInput.value = rgbaToHex(themeValue);
}
}
});
}
_dispatchConfigChange() {
clearTimeout(this._debounceTimer);
this._debounceTimer = setTimeout(() => {
// Preserve current tab state
const activeTab = this._root.querySelector('.tab.active');
const activeTabId = activeTab ? activeTab.dataset.tab : 'entities';
this.dispatchEvent(new CustomEvent("config-changed", { detail: { config: this._cfg } }));
// Restore tab state after a brief delay
setTimeout(() => {
this._restoreTabState(activeTabId);
}, 50);
}, 120);
}
_restoreTabState(activeTabId) {
// Switch to the preserved tab
const tabs = this._root.querySelectorAll('.tab');
const tabContents = this._root.querySelectorAll('.tab-content');
tabs.forEach(tab => {
tab.classList.remove('active');
if (tab.dataset.tab === activeTabId) {
tab.classList.add('active');
}
});
tabContents.forEach(content => {
content.classList.remove('active');
if (content.id === `${activeTabId}-tab`) {
content.classList.add('active');
}
});
}
}
customElements.define(EDITOR_TAG, KPrinterCardEditor);
try {
window.customCards = window.customCards || [];
window.customCards.push({
type: CARD_TAG,
name: "Creality Printer Card",
description: "Standalone card for Creality K-Series printers",
preview: true,
});
} catch (_) { }