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 = `
`;
// 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 += ``;
}
});
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 = `
${this._t("editor_title")}
${this._t("tab_entities")}
${this._t("tab_theme")}
${this._t("group_layout")}
${this._t("group_action_colors")}
${this._t("color_pause_bg")}
⏸
${this._t("color_pause_icon")}
${this._t("color_resume_bg")}
▶
${this._t("color_resume_icon")}
${this._t("color_stop_bg")}
⏹
${this._t("color_stop_icon")}
${this._t("color_light_on_bg")}
${this._t("color_light_off_bg")}
💡
${this._t("color_light_icon_on")}
💡
${this._t("color_light_icon_off")}
${this._t("color_custom_bg")}
★
${this._t("color_custom_icon")}
${this._t("group_status_area")}
🖨
${this._t("color_status_icon")}
⭕
${this._t("color_progress_ring")}
🎯
${this._t("color_status_bg")}
${this._t("group_telemetry")}
🌡
${this._t("color_telemetry_icon")}
📝
${this._t("color_telemetry_text")}
`;
this._setupTabs();
this._setupEntitiesForm();
this._setupThemeEditor();
} catch (error) {
console.error('Error rendering K-Printer Card Editor:', error);
this._root.innerHTML = `
Editor Error
${this._t("editor_error_msg")}
${this._t("editor_error_prefix")} ${error.message}
`;
}
}
_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 (_) { }