From 729ad52207fc08e3ad00e753b223109146d6bd23 Mon Sep 17 00:00:00 2001 From: Jonas Niesner Date: Fri, 5 Jun 2026 18:52:35 +0200 Subject: [PATCH] Add initial version of visual editor --- custom_components/opendisplay/__init__.py | 2 + .../opendisplay/designer/__init__.py | 46 + .../opendisplay/designer/capabilities.py | 140 ++ .../opendisplay/designer/const.py | 6 + .../designer/frontend/app/designer_extras.js | 624 ++++++++ .../designer/frontend/app/dimensions.js | 245 +++ .../frontend/app/entity_autocomplete.js | 834 ++++++++++ .../designer/frontend/app/export.js | 94 ++ .../opendisplay/designer/frontend/app/main.js | 808 ++++++++++ .../designer/frontend/app/preview.js | 416 +++++ .../designer/frontend/app/preview_sketch.js | 846 ++++++++++ .../designer/frontend/app/sketch_edit.js | 454 ++++++ .../designer/frontend/app/sketch_hit.js | 728 +++++++++ .../designer/frontend/app/styles.css | 1395 +++++++++++++++++ .../designer/frontend/app/yaml_gutter.js | 118 ++ .../designer/frontend/app/yaml_util.js | 154 ++ .../panel/opendisplay-designer-panel.js | 45 + .../designer/frontend/vendor/js-yaml.mjs | 9 + .../opendisplay/designer/image_entity.py | 94 ++ .../opendisplay/designer/panel.py | 116 ++ custom_components/opendisplay/image.py | 22 +- custom_components/opendisplay/manifest.json | 2 +- 22 files changed, 7195 insertions(+), 3 deletions(-) create mode 100644 custom_components/opendisplay/designer/__init__.py create mode 100644 custom_components/opendisplay/designer/capabilities.py create mode 100644 custom_components/opendisplay/designer/const.py create mode 100644 custom_components/opendisplay/designer/frontend/app/designer_extras.js create mode 100644 custom_components/opendisplay/designer/frontend/app/dimensions.js create mode 100644 custom_components/opendisplay/designer/frontend/app/entity_autocomplete.js create mode 100644 custom_components/opendisplay/designer/frontend/app/export.js create mode 100644 custom_components/opendisplay/designer/frontend/app/main.js create mode 100644 custom_components/opendisplay/designer/frontend/app/preview.js create mode 100644 custom_components/opendisplay/designer/frontend/app/preview_sketch.js create mode 100644 custom_components/opendisplay/designer/frontend/app/sketch_edit.js create mode 100644 custom_components/opendisplay/designer/frontend/app/sketch_hit.js create mode 100644 custom_components/opendisplay/designer/frontend/app/styles.css create mode 100644 custom_components/opendisplay/designer/frontend/app/yaml_gutter.js create mode 100644 custom_components/opendisplay/designer/frontend/app/yaml_util.js create mode 100644 custom_components/opendisplay/designer/frontend/panel/opendisplay-designer-panel.js create mode 100644 custom_components/opendisplay/designer/frontend/vendor/js-yaml.mjs create mode 100644 custom_components/opendisplay/designer/image_entity.py create mode 100644 custom_components/opendisplay/designer/panel.py diff --git a/custom_components/opendisplay/__init__.py b/custom_components/opendisplay/__init__.py index 3bfb97d..499f31e 100644 --- a/custom_components/opendisplay/__init__.py +++ b/custom_components/opendisplay/__init__.py @@ -33,6 +33,7 @@ from .const import CONF_ENCRYPTION_KEY, DOMAIN from .coordinator import OpenDisplayCoordinator +from .designer import async_setup_designer from .services import async_setup_services CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -75,6 +76,7 @@ def _get_encryption_key(entry: OpenDisplayConfigEntry) -> bytes | None: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the OpenDisplay integration.""" async_setup_services(hass) + await async_setup_designer(hass) return True diff --git a/custom_components/opendisplay/designer/__init__.py b/custom_components/opendisplay/designer/__init__.py new file mode 100644 index 0000000..d840fd0 --- /dev/null +++ b/custom_components/opendisplay/designer/__init__.py @@ -0,0 +1,46 @@ +"""OpenDisplay image designer panel.""" + +from __future__ import annotations + +import logging + +from homeassistant.core import HomeAssistant + +from ..const import DOMAIN +from .const import DESIGNER_PANEL_PATH +from .panel import OpenDisplayDesignerStaticView, async_get_panel_module_url + +_LOGGER = logging.getLogger(__name__) + +_DESIGNER_KEY = "designer" + + +async def async_setup_designer(hass: HomeAssistant) -> None: + """Register designer static assets and sidebar panel.""" + hass.data.setdefault(DOMAIN, {}) + designer_data = hass.data[DOMAIN].setdefault(_DESIGNER_KEY, {}) + + if not designer_data.get("views_registered"): + hass.http.register_view(OpenDisplayDesignerStaticView(hass)) + designer_data["views_registered"] = True + _LOGGER.debug("Registered OpenDisplay designer static assets") + + if designer_data.get("panel_registered"): + return + + try: + from homeassistant.components import panel_custom + + await panel_custom.async_register_panel( + hass, + frontend_url_path=DESIGNER_PANEL_PATH, + webcomponent_name="opendisplay-designer-panel", + sidebar_title="OpenDisplay Designer", + sidebar_icon="mdi:monitor-edit", + module_url=await async_get_panel_module_url(hass), + require_admin=False, + ) + designer_data["panel_registered"] = True + _LOGGER.info("OpenDisplay designer panel registered") + except (AttributeError, ImportError, RuntimeError, ValueError) as err: + _LOGGER.warning("Failed to register OpenDisplay designer panel: %s", err) diff --git a/custom_components/opendisplay/designer/capabilities.py b/custom_components/opendisplay/designer/capabilities.py new file mode 100644 index 0000000..ab10758 --- /dev/null +++ b/custom_components/opendisplay/designer/capabilities.py @@ -0,0 +1,140 @@ +"""Build designer-facing device capability payloads from runtime config.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +_LOGGER = logging.getLogger(__name__) + +from epaper_dithering import ColorPalette, ColorScheme +from opendisplay import Rotation +from opendisplay.display_palettes import get_palette_for_display + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH + +if TYPE_CHECKING: + from .. import OpenDisplayConfigEntry + + +def _rotation_degrees(rotation: Rotation | int) -> int: + if isinstance(rotation, Rotation): + return int(rotation.value) + return int(rotation) % 360 + + +def _color_scheme_enum(display: Any) -> ColorScheme: + cs = display.color_scheme_enum + if isinstance(cs, ColorScheme): + return cs + return ColorScheme.from_value(int(cs)) + + +def _palette_for_display(display: Any) -> ColorPalette | ColorScheme: + scheme = _color_scheme_enum(display) + return get_palette_for_display(display.panel_ic_type, scheme) + + +def _palette_color_map(palette: ColorPalette | ColorScheme) -> dict[str, str]: + colors = ( + palette.colors + if isinstance(palette, ColorPalette) + else palette.palette.colors + ) + out: dict[str, str] = {} + for name, rgb in colors.items(): + if not isinstance(rgb, (tuple, list)) or len(rgb) < 3: + continue + r, g, b = (int(rgb[0]), int(rgb[1]), int(rgb[2])) + out[str(name)] = f"#{r:02x}{g:02x}{b:02x}" + return out + + +def _render_dimensions( + pixel_width: int, + pixel_height: int, + base_rotation_deg: int, + user_rotate_deg: int = 0, +) -> tuple[int, int]: + """Match drawcustom canvas sizing for base + user rotation.""" + effective = (base_rotation_deg + user_rotate_deg) % 360 + if effective in (90, 270): + return pixel_height, pixel_width + return pixel_width, pixel_height + + +def resolve_device_id_for_entry( + hass: HomeAssistant, entry: OpenDisplayConfigEntry +) -> str | None: + """Resolve HA device registry id for a config entry.""" + device_registry = dr.async_get(hass) + by_config_entry = getattr( + device_registry.devices, "get_devices_for_config_entry_id", None + ) + if callable(by_config_entry): + devices = by_config_entry(entry.entry_id) + if devices: + return devices[0].id + device = device_registry.async_get_device( + identifiers={(CONNECTION_BLUETOOTH, entry.unique_id)} + ) + if device is not None: + return device.id + mac = entry.unique_id + if mac: + for variant in {mac, mac.upper(), mac.lower()}: + device = device_registry.async_get_device( + identifiers={(CONNECTION_BLUETOOTH, variant)} + ) + if device is not None: + return device.id + return None + + +def build_capabilities( + entry: OpenDisplayConfigEntry, + device_id: str, + *, + user_rotate_deg: int = 0, +) -> dict[str, Any]: + """Serialize display capabilities for the designer frontend.""" + display = entry.runtime_data.device_config.displays[0] + scheme_enum = _color_scheme_enum(display) + palette = _palette_for_display(display) + color_map = _palette_color_map(palette) + available_colors = list(color_map.keys()) + base_rotation = _rotation_degrees(display.rotation_enum) + render_w, render_h = _render_dimensions( + display.pixel_width, + display.pixel_height, + base_rotation, + user_rotate_deg, + ) + + measured = isinstance(palette, ColorPalette) + accent = ( + palette.accent + if isinstance(palette, ColorPalette) + else scheme_enum.accent_color + ) + + diagonal = display.screen_diagonal_inches + return { + "device_id": device_id, + "pixel_width": int(display.pixel_width), + "pixel_height": int(display.pixel_height), + "screen_diagonal_inches": float(diagonal) if diagonal is not None else None, + "rotation_degrees": int(base_rotation), + "render_width": int(render_w), + "render_height": int(render_h), + "color_scheme": str(scheme_enum.name), + "color_mode": str(scheme_enum.name), + "color_scheme_value": int(scheme_enum.value), + "panel_ic_type": str(display.panel_ic_type), + "accent_color": str(accent), + "available_colors": available_colors, + "color_map": color_map, + "palette_measured": bool(measured), + } diff --git a/custom_components/opendisplay/designer/const.py b/custom_components/opendisplay/designer/const.py new file mode 100644 index 0000000..1173f99 --- /dev/null +++ b/custom_components/opendisplay/designer/const.py @@ -0,0 +1,6 @@ +"""Designer panel paths and URLs (kept out of integration root const).""" + +from ..const import DOMAIN + +DESIGNER_PANEL_PATH = "opendisplay-designer" +DESIGNER_STATIC_URL = f"/api/{DOMAIN}/designer/static" diff --git a/custom_components/opendisplay/designer/frontend/app/designer_extras.js b/custom_components/opendisplay/designer/frontend/app/designer_extras.js new file mode 100644 index 0000000..604a356 --- /dev/null +++ b/custom_components/opendisplay/designer/frontend/app/designer_extras.js @@ -0,0 +1,624 @@ +/** + * Designer power features: inspector, shortcuts, diff, blueprint. + */ + +import { replaceRange } from './entity_autocomplete.js'; + +/** @type {Record} */ +export const INSPECTOR_FIELDS = { + debug_grid: [ + 'spacing', + 'line_color', + 'dashed', + 'dash_length', + 'space_length', + 'show_labels', + 'label_step', + 'label_color', + 'label_font_size', + 'font', + 'visible', + ], + text: [ + 'x', + 'y', + 'value', + 'size', + 'font', + 'color', + 'anchor', + 'max_width', + 'spacing', + 'stroke_width', + 'stroke_fill', + 'y_padding', + 'visible', + 'parse_colors', + 'truncate', + ], + multiline: [ + 'x', + 'y', + 'value', + 'delimiter', + 'offset_y', + 'size', + 'font', + 'color', + 'spacing', + 'visible', + 'parse_colors', + ], + line: [ + 'x_start', + 'y_start', + 'x_end', + 'y_end', + 'fill', + 'width', + 'y_padding', + 'dashed', + 'dash_length', + 'space_length', + 'visible', + ], + rectangle: [ + 'x_start', + 'y_start', + 'x_end', + 'y_end', + 'fill', + 'outline', + 'width', + 'radius', + 'corners', + 'visible', + ], + rectangle_pattern: [ + 'x_start', + 'y_start', + 'x_size', + 'y_size', + 'x_offset', + 'y_offset', + 'x_repeat', + 'y_repeat', + 'fill', + 'outline', + 'width', + 'visible', + ], + polygon: ['points', 'fill', 'outline', 'width', 'visible'], + circle: ['x', 'y', 'radius', 'fill', 'outline', 'width', 'visible'], + ellipse: ['x_start', 'y_start', 'x_end', 'y_end', 'fill', 'outline', 'width', 'visible'], + arc: ['x', 'y', 'radius', 'start_angle', 'end_angle', 'fill', 'outline', 'width', 'visible'], + icon: ['x', 'y', 'value', 'size', 'fill', 'color', 'anchor', 'visible'], + icon_sequence: [ + 'x', + 'y', + 'icons', + 'size', + 'direction', + 'spacing', + 'fill', + 'anchor', + 'visible', + ], + dlimg: ['url', 'x', 'y', 'xsize', 'ysize', 'resize_method', 'rotate', 'visible'], + qrcode: ['data', 'x', 'y', 'boxsize', 'border', 'color', 'bgcolor', 'visible'], + qr_code: ['data', 'x', 'y', 'boxsize', 'border', 'color', 'bgcolor', 'visible'], + plot: [ + 'x_start', + 'y_start', + 'x_end', + 'y_end', + 'data', + 'duration', + 'low', + 'high', + 'font', + 'size', + 'round_values', + 'debug', + 'ylegend', + 'yaxis', + 'xlegend', + 'xaxis', + 'visible', + ], + progress_bar: [ + 'x_start', + 'y_start', + 'x_end', + 'y_end', + 'progress', + 'direction', + 'background', + 'fill', + 'outline', + 'width', + 'show_percentage', + 'font', + 'visible', + ], + diagram: ['x', 'y', 'width', 'height', 'margin', 'bars', 'visible'], +}; + +const INSPECTOR_COLOR_KEYS = new Set([ + 'color', + 'fill', + 'outline', + 'background', + 'bgcolor', + 'stroke_fill', + 'line_color', + 'label_color', + 'legend_color', +]); + +/** Fields where bare entity ids are wrapped as {{ states('…') }} */ +const INSPECTOR_ENTITY_KEYS = new Set([ + 'value', + 'url', + 'progress', + 'color', + 'fill', + 'outline', + 'background', + 'stroke_fill', + 'legend_color', +]); + +/** + * @param {string} key + * @param {string} elementType + */ +function fieldPlaceholder(key, elementType) { + if (key === 'value') return "Hello or {{ states('sensor.time') }}"; + if (key === 'url') return "/local/image.png or {{ states('camera.x') }}"; + if (key === 'progress') return "62 or {{ states('sensor.battery') | int }}"; + if (INSPECTOR_COLOR_KEYS.has(key)) { + return "black or {{ 'accent' if is_state('…') else 'black' }}"; + } + if (key === 'data' && elementType === 'plot') { + return '[{"entity":"sensor.temperature","color":"black","width":2}]'; + } + if (key === 'data') return 'https://example.com or text'; + if (key === 'bars' && elementType === 'diagram') { + return '{"values":"A,8;B,14;C,10","color":"black"}'; + } + return ''; +} + +/** + * @param {string} key + * @param {string} elementType + * @returns {Array<{ label: string; insert: string }> | null} + */ +function templateChipsForField(key, elementType) { + if (key === 'value') { + /** @type {Array<{ label: string; insert: string }>} */ + const chips = [ + { label: 'Entity', insert: "{{ states('') }}" }, + { label: 'Attribute', insert: "{{ state_attr('', '') }}" }, + { label: 'Condition', insert: "{{ is_state('', 'on') }}" }, + ]; + if (elementType === 'text' || elementType === 'multiline') { + chips.push({ label: 'Accent', insert: '[accent][/accent]' }); + } + return chips; + } + if (key === 'url' || key === 'progress') { + return [ + { label: 'Entity', insert: "{{ states('') }}" }, + { label: 'Attribute', insert: "{{ state_attr('', '') }}" }, + { label: 'Condition', insert: "{{ is_state('', 'on') }}" }, + ]; + } + if (INSPECTOR_COLOR_KEYS.has(key)) { + return [ + { label: 'Entity', insert: "{{ states('') }}" }, + { label: 'Condition', insert: "{{ is_state('', 'on') }}" }, + { label: 'Conditional', insert: "{{ 'accent' if is_state('', 'on') else 'black' }}" }, + ]; + } + return null; +} + +/** + * @param {HTMLElement} labelRow + * @param {HTMLInputElement | HTMLTextAreaElement} inp + * @param {Array<{ label: string; insert: string }>} chips + */ +function appendFieldTemplateChips(labelRow, inp, chips) { + const row = document.createElement('span'); + row.className = 'od-inspector-field-chips'; + for (const chip of chips) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'od-tpl-chip'; + btn.textContent = chip.label; + btn.title = chip.insert; + btn.addEventListener('mousedown', (e) => e.preventDefault()); + btn.addEventListener('click', () => { + inp.focus(); + const start = inp.selectionStart ?? inp.value.length; + const end = inp.selectionEnd ?? start; + replaceRange(inp, start, end, chip.insert); + }); + row.appendChild(btn); + } + labelRow.appendChild(row); +} + +const INSPECTOR_LONG_KEYS = new Set([ + 'value', + 'data', + 'points', + 'icons', + 'bars', + 'ylegend', + 'yaxis', + 'xlegend', + 'xaxis', +]); + +const INSPECTOR_JSON_KEYS = new Set([ + 'data', + 'points', + 'icons', + 'bars', + 'ylegend', + 'yaxis', + 'xlegend', + 'xaxis', +]); + +const INSPECTOR_BOOL_KEYS = new Set([ + 'visible', + 'parse_colors', + 'truncate', + 'dashed', + 'show_labels', + 'show_percentage', + 'round_values', + 'debug', +]); + +const INSPECTOR_NUMERIC_KEYS = new Set([ + 'size', + 'offset_y', + 'width', + 'radius', + 'progress', + 'duration', + 'low', + 'high', + 'spacing', + 'boxsize', + 'border', + 'xsize', + 'ysize', + 'rotate', + 'margin', + 'height', + 'start_angle', + 'end_angle', + 'dash_length', + 'space_length', + 'label_step', + 'label_font_size', + 'x_repeat', + 'y_repeat', + 'x_size', + 'y_size', + 'x_offset', + 'y_offset', + 'stroke_width', + 'y_padding', + 'max_width', +]); + +/** @param {unknown} v */ +function isPlainColorValue(v) { + const s = String(v ?? '').trim(); + if (!s) return true; + return !s.includes('{{') && !s.includes('{%') && !s.includes('['); +} + +/** + * @param {Record} el + * @param {string} key + * @param {string} value + */ +export function applyInspectorFieldValue(el, key, value) { + if (INSPECTOR_JSON_KEYS.has(key)) { + const trimmed = value.trim(); + if (!trimmed) { + delete el[key]; + return; + } + try { + el[key] = JSON.parse(trimmed); + } catch { + el[key] = value; + } + return; + } + if (INSPECTOR_BOOL_KEYS.has(key)) { + const s = value.trim().toLowerCase(); + if (s === 'true' || s === 'false') { + el[key] = s === 'true'; + return; + } + } + if ( + INSPECTOR_NUMERIC_KEYS.has(key) || + key === 'x' || + key === 'y' || + key.startsWith('x_') || + key.startsWith('y_') + ) { + const n = Number(value); + el[key] = Number.isFinite(n) ? n : value; + return; + } + el[key] = value; +} + +export function resolveInspectorFields(type, item) { + const known = INSPECTOR_FIELDS[type] || []; + const seen = new Set(known); + /** @type {string[]} */ + const extra = []; + for (const key of Object.keys(item)) { + if (key === 'type' || seen.has(key)) continue; + seen.add(key); + extra.push(key); + } + extra.sort(); + return [...known, ...extra]; +} + +/** + * @param {unknown} v + * @param {string} [key] + * @param {string} [elementType] + */ +function fieldDisplayValue(v, key, elementType) { + if (v === undefined || v === null) return ''; + if (typeof v === 'object') { + try { + if (key === 'data' && elementType === 'plot') { + return JSON.stringify(v); + } + return JSON.stringify(v, null, 2); + } catch { + return String(v); + } + } + return String(v); +} + +/** + * @param {HTMLElement} panel + * @param {number} idx + * @param {Record | null} item + * @param {{ availableColors?: string[] }} [options] + * @param {(idx: number, key: string, value: string) => void} onChange + */ +export function renderInspector(panel, idx, item, options, onChange) { + panel.replaceChildren(); + if (!item || idx < 0) { + panel.hidden = true; + return; + } + panel.hidden = false; + const head = document.createElement('div'); + head.className = 'od-inspector-head'; + head.innerHTML = `

Element ${idx + 1}

${String(item.type || '')}`; + panel.appendChild(head); + + const type = String(item.type || '').toLowerCase(); + + const fields = resolveInspectorFields(type, item); + const colors = options?.availableColors?.length ? options.availableColors : []; + const form = document.createElement('div'); + form.className = 'od-inspector-form'; + for (const key of fields) { + const field = document.createElement('label'); + field.className = 'od-inspector-field'; + const lab = document.createElement('span'); + lab.className = 'od-inspector-field-label'; + const keySpan = document.createElement('span'); + keySpan.className = 'od-inspector-field-key'; + keySpan.textContent = key; + lab.appendChild(keySpan); + const cur = fieldDisplayValue(item[key], key, type); + const useColorSelect = + INSPECTOR_COLOR_KEYS.has(key) && colors.length && isPlainColorValue(item[key]); + if (useColorSelect) { + const sel = document.createElement('select'); + const opts = [...colors]; + const curStr = String(item[key] ?? ''); + if (curStr && !opts.includes(curStr)) opts.unshift(curStr); + for (const c of opts) { + const opt = document.createElement('option'); + opt.value = c; + opt.textContent = c; + sel.appendChild(opt); + } + sel.value = curStr || colors[0] || ''; + sel.addEventListener('change', () => onChange(idx, key, sel.value)); + field.append(lab, sel); + } else { + const isLong = INSPECTOR_LONG_KEYS.has(key); + const inp = isLong ? document.createElement('textarea') : document.createElement('input'); + if (isLong) { + inp.rows = key === 'value' ? 3 : 4; + } else { + inp.type = 'text'; + } + inp.value = cur; + inp.dataset.odKey = key; + const ph = fieldPlaceholder(key, type); + if (ph) inp.placeholder = ph; + if (INSPECTOR_ENTITY_KEYS.has(key)) inp.dataset.odAcEntity = '1'; + if (key === 'data' && type === 'plot') inp.dataset.odAcEntity = '0'; + if (INSPECTOR_COLOR_KEYS.has(key)) inp.dataset.odAcColors = '1'; + if (key === 'data' && type === 'plot') { + inp.title = 'Entity IDs in JSON — type sensor. or Ctrl+Space'; + } + const chips = templateChipsForField(key, type); + if (chips) { + appendFieldTemplateChips(lab, inp, chips); + inp.title = 'Templates supported — type {{ or sensor., Ctrl+Space for suggestions'; + } + inp.addEventListener('change', () => onChange(idx, key, inp.value)); + const wrap = document.createElement('div'); + wrap.className = 'od-inspector-field-wrap'; + wrap.appendChild(inp); + field.append(lab, wrap); + } + form.appendChild(field); + } + panel.appendChild(form); + + const jump = document.createElement('button'); + jump.type = 'button'; + jump.className = 'secondary od-inspector-jump'; + jump.textContent = 'Jump to YAML'; + jump.addEventListener('click', () => { + panel.dispatchEvent(new CustomEvent('od-jump-yaml', { bubbles: true, detail: { idx } })); + }); + panel.appendChild(jump); +} + +/** + * @param {HTMLElement} root + * @param {(tab: string) => void} onTab + */ +export function setupWorkspaceTabs(root, onTab) { + const tabs = root.querySelectorAll('.od-workspace-tabs [data-od-tab]'); + tabs.forEach((btn) => { + btn.addEventListener('click', () => { + const tab = /** @type {HTMLElement} */ (btn).dataset.odTab || 'yaml'; + root.classList.remove('od-tab-yaml', 'od-tab-visual', 'od-tab-export'); + root.classList.add(`od-tab-${tab}`); + tabs.forEach((b) => b.classList.toggle('active', b === btn)); + onTab(tab); + }); + }); +} + +/** + * @param {HTMLElement} shadow + * @param {Record} shortcuts + */ +export function setupShortcutsModal(shadow, shortcuts) { + const backdrop = shadow.querySelector('#od-shortcuts-modal'); + const list = shadow.querySelector('#od-shortcuts-list'); + const closeBtn = shadow.querySelector('#od-shortcuts-close'); + if (!backdrop || !list) return { open: () => {}, close: () => {} }; + + list.replaceChildren(); + for (const [key, desc] of Object.entries(shortcuts)) { + const row = document.createElement('div'); + row.className = 'od-shortcut-row'; + row.innerHTML = `${key}${desc}`; + list.appendChild(row); + } + + const close = () => { + backdrop.hidden = true; + }; + const open = () => { + backdrop.hidden = false; + }; + closeBtn?.addEventListener('click', close); + backdrop.addEventListener('click', (e) => { + if (e.target === backdrop) close(); + }); + return { open, close }; +} + +/** + * @param {HTMLElement} shadow + * @param {() => string} getCurrentYaml + * @param {() => string | null} getLastHaYaml + */ +export function setupDiffModal(shadow, getCurrentYaml, getLastHaYaml) { + const backdrop = shadow.querySelector('#od-diff-modal'); + const body = shadow.querySelector('#od-diff-body'); + const closeBtn = shadow.querySelector('#od-diff-close'); + const openBtn = shadow.querySelector('#od-diff-open'); + if (!backdrop || !body) return { open: () => {}, close: () => {} }; + + const close = () => { + backdrop.hidden = true; + }; + const open = () => { + const left = getLastHaYaml() || '(no HA preview yet)'; + const right = getCurrentYaml(); + body.replaceChildren(); + const wrap = document.createElement('div'); + wrap.className = 'od-diff-columns'; + const colA = document.createElement('pre'); + colA.textContent = left; + const colB = document.createElement('pre'); + colB.textContent = right; + wrap.append(colA, colB); + body.appendChild(wrap); + backdrop.hidden = false; + }; + closeBtn?.addEventListener('click', close); + openBtn?.addEventListener('click', open); + backdrop.addEventListener('click', (e) => { + if (e.target === backdrop) close(); + }); + return { open, close }; +} + +/** + * @param {string} payloadYamlIndented + */ +export function buildBlueprintSnippet(payloadYamlIndented) { + return `blueprint: + name: OpenDisplay drawcustom + domain: automation + input: + device_id: + name: OpenDisplay device + selector: + device: + integration: opendisplay + background: + name: Background + default: white + selector: + select: + options: + - white + - black + - accent + - red + - yellow + source_url: https://github.com/OpenDisplay/Home_Assistant_Integration +variables: + device_id: !input device_id + background: !input background +trigger: [] +action: + - action: opendisplay.drawcustom + target: + device_id: "{{ device_id }}" + data: + background: !input background + rotate: 0 + dither: ordered + refresh_type: full + dry-run: false + payload: +${payloadYamlIndented} +`; +} diff --git a/custom_components/opendisplay/designer/frontend/app/dimensions.js b/custom_components/opendisplay/designer/frontend/app/dimensions.js new file mode 100644 index 0000000..7090ded --- /dev/null +++ b/custom_components/opendisplay/designer/frontend/app/dimensions.js @@ -0,0 +1,245 @@ +/** Device list, tag dimensions, and display colors from image.* entity attributes. */ + +export const VIRTUAL_DEVICE_ID = '__virtual__'; +export const DEFAULT_TAG_SIZE = { tagW: 296, tagH: 128 }; +export const COLOR_FALLBACK = /** @type {const} */ (['white', 'black']); + +const OPENDISPLAY_DOMAINS = /** @type {const} */ (['opendisplay']); +const COLOR_SYNONYMS = { + b: 'black', + w: 'white', + r: 'red', + y: 'yellow', + a: 'accent', + ha: 'accent', +}; + +/** @param {unknown} val */ +export function parsePositiveInt(val) { + const n = parseInt(String(val ?? '').trim(), 10); + return Number.isFinite(n) && n > 0 ? n : 0; +} + +/** @param {string} c */ +export function normalizeColorName(c) { + const s = String(c || '').trim().toLowerCase(); + if (!s) return ''; + if (COLOR_SYNONYMS[s]) return COLOR_SYNONYMS[s]; + return s; +} + +/** @param {string[]} colors */ +export function uniqueColors(colors) { + const out = []; + const seen = new Set(); + for (const cRaw of colors) { + const c = normalizeColorName(cRaw); + if (!c || seen.has(c)) continue; + seen.add(c); + out.push(c); + } + return out; +} + +/** + * @param {any} hass + * @returns {Array<{ id: string; name: string }>} + */ +export function listOpenDisplayDevices(hass) { + const devices = hass?.devices; + if (!devices || typeof devices !== 'object') return []; + /** @type {Array<{ id: string; name: string }>} */ + const out = []; + for (const [id, d] of Object.entries(devices)) { + if (!d || typeof d !== 'object') continue; + const ids = /** @type {{ identifiers?: unknown }} */ (d).identifiers; + let hit = + Array.isArray(ids) && + ids.some( + /** @returns {boolean} */ (tuple) => + Array.isArray(tuple) && OPENDISPLAY_DOMAINS.includes(tuple[0]) + ); + if (!hit && hass?.entities) { + hit = Object.values(hass.entities).some( + (e) => e && e.device_id === id && e.platform === 'opendisplay' + ); + } + if (!hit) continue; + const dn = /** @type {any} */ (d); + const name = String(dn.name_by_user || dn.name || dn.original_name || id).trim(); + out.push({ id, name }); + } + out.sort((a, b) => a.name.localeCompare(b.name)); + out.push({ id: VIRTUAL_DEVICE_ID, name: 'Virtual device (local sketch)' }); + return out; +} + +/** + * @param {any} hass + * @param {string} deviceId + */ +function entitiesForDevice(hass, deviceId) { + const reg = hass?.entities; + if (!reg || typeof reg !== 'object') return []; + return Object.entries(reg).filter( + ([, e]) => e && typeof e === 'object' && e.device_id === deviceId + ); +} + +/** @param {any} ent @param {string} key */ +function entityIdFromEntry(key, ent) { + return String(ent?.entity_id || key); +} + +/** + * @param {any} hass + * @param {string} deviceId + * @returns {string | null} + */ +export function imageEntityForDevice(hass, deviceId) { + /** @type {string[]} */ + const imgs = []; + for (const [key, ent] of entitiesForDevice(hass, deviceId)) { + const eid = entityIdFromEntry(key, ent); + if (eid.startsWith('image.')) imgs.push(eid); + } + if (imgs.length === 0) return null; + return ( + imgs.find((eid) => parsePositiveInt(hass.states[eid]?.attributes?.pixel_width) > 0) ?? + imgs[0] + ); +} + +/** + * @param {any} hass + * @param {string} deviceId + */ +export function imageDisplayAttributes(hass, deviceId) { + const eid = imageEntityForDevice(hass, deviceId); + if (!eid) return null; + const attrs = hass?.states?.[eid]?.attributes; + if (!attrs || typeof attrs !== 'object') return null; + return attrs; +} + +/** + * @param {any} hass + * @param {string} eid + * @returns {{ tagW: number; tagH: number } | null} + */ +function dimsFromEntityState(hass, eid) { + const st = hass?.states?.[eid]; + if (!st) return null; + const attrs = st.attributes && typeof st.attributes === 'object' ? st.attributes : {}; + const pw = parsePositiveInt(attrs.pixel_width); + const ph = parsePositiveInt(attrs.pixel_height); + if (pw && ph) return { tagW: pw, tagH: ph }; + const m = String(st.state ?? '').match(/^(\d+)\s*[x×]\s*(\d+)$/); + if (!m) return null; + const tagW = Number(m[1]); + const tagH = Number(m[2]); + if (!(tagW > 0 && tagH > 0)) return null; + return { tagW, tagH }; +} + +/** + * @param {any} hass + * @param {string} deviceId + */ +export function dimensionsFromImageEntity(hass, deviceId) { + const eid = imageEntityForDevice(hass, deviceId); + if (!eid) return null; + return dimsFromEntityState(hass, eid); +} + +/** + * @param {HTMLImageElement | null | undefined} imgEl + */ +export function dimensionsFromPreviewImage(imgEl) { + if (!imgEl || imgEl.hidden) return null; + if (!imgEl.complete) return null; + const nw = imgEl.naturalWidth; + const nh = imgEl.naturalHeight; + if (!(nw > 8 && nh > 8)) return null; + return { tagW: nw, tagH: nh }; +} + +/** + * @param {any} hass + * @param {string | null | undefined} deviceId + * @param {HTMLImageElement | null | undefined} previewImgEl + * @param {{ w: number; h: number }} virtualDims + */ +export function resolveTagPx(hass, deviceId, previewImgEl, virtualDims) { + const fallback = { ...DEFAULT_TAG_SIZE }; + if (!deviceId) return { ...fallback, source: 'fallback_no_device' }; + if (deviceId === VIRTUAL_DEVICE_ID) { + return { + tagW: virtualDims.w || fallback.tagW, + tagH: virtualDims.h || fallback.tagH, + source: 'virtual_inputs', + }; + } + const fromImage = dimensionsFromImageEntity(hass, deviceId); + if (fromImage) return { ...fromImage, source: 'image_entity' }; + const fromPreview = dimensionsFromPreviewImage(previewImgEl); + if (fromPreview) return { ...fromPreview, source: 'preview_image' }; + return { ...fallback, source: 'fallback_default' }; +} + +/** + * @param {Record | null | undefined} attrs + */ +function colorsFromImageAttributes(attrs) { + if (!attrs) return null; + if (Array.isArray(attrs.available_colors) && attrs.available_colors.length) { + return uniqueColors(attrs.available_colors.map((c) => String(c))); + } + return null; +} + +/** + * @param {any} hass + * @param {string | null | undefined} deviceId + */ +export function availableDisplayColors(hass, deviceId) { + if (!deviceId || deviceId === VIRTUAL_DEVICE_ID) return [...COLOR_FALLBACK]; + const fromImage = colorsFromImageAttributes(imageDisplayAttributes(hass, deviceId)); + return fromImage?.length ? fromImage : [...COLOR_FALLBACK]; +} + +/** + * @param {any} hass + * @param {string} deviceId + */ +export function colorMapForDevice(hass, deviceId) { + const attrs = imageDisplayAttributes(hass, deviceId); + const map = attrs?.color_map; + if (map && typeof map === 'object') return { ...map }; + return null; +} + +/** + * @param {string} name + * @param {any} hass + * @param {string | null | undefined} deviceId + */ +export function colorChipHex(name, hass, deviceId) { + const n = normalizeColorName(name); + const map = + deviceId && deviceId !== VIRTUAL_DEVICE_ID ? colorMapForDevice(hass, deviceId) : null; + if (map) { + const fromMap = map[name] ?? map[n]; + if (fromMap) return fromMap; + } + if (n === 'black') return '#000000'; + if (n === 'white') return '#ffffff'; + if (n === 'red') return '#cc0000'; + if (n === 'yellow') return '#d8b400'; + if (n === 'accent') return '#2a6cff'; + if (n === 'blue') return '#0022cc'; + if (n === 'green') return '#008800'; + if (n === 'gray' || n === 'grey' || n === 'half_black') return '#888'; + if (n.startsWith('#')) return n; + return '#666'; +} diff --git a/custom_components/opendisplay/designer/frontend/app/entity_autocomplete.js b/custom_components/opendisplay/designer/frontend/app/entity_autocomplete.js new file mode 100644 index 0000000..7d077ea --- /dev/null +++ b/custom_components/opendisplay/designer/frontend/app/entity_autocomplete.js @@ -0,0 +1,834 @@ +/** + * Lightweight entity_id completion for a textarea (domain.entity style + states('...') context). + */ + +const ENTITY_PARTIAL = '[a-zA-Z][a-zA-Z0-9_]*(?:\\.[a-zA-Z0-9_.]*)?'; + +/** + * True when the cursor sits in a plain YAML `key: value` line (not a template). + * @param {string} text + * @param {number} caret + */ +function isYamlPlainValueContext(text, caret) { + const before = text.slice(0, caret); + const lineStart = before.lastIndexOf('\n') + 1; + const line = before.slice(lineStart); + if ( + line.includes('{{') || + /\bstates\s*\(/.test(line) || + /\bstate_attr\s*\(/.test(line) || + /\bis_state\s*\(/.test(line) + ) { + return false; + } + return /^\s*[\w-]+\s*:\s*\S*$/.test(line); +} + +/** + * @returns {{ start: number; partial: string; quote: string | null } | null} + */ +function getStatesTokenAtCursor(text, caret) { + const before = text.slice(0, caret); + const empty = before.match(/\bstates\s*\(\s*(['"])\s*$/); + if (empty) { + return { start: caret, partial: '', quote: empty[1] }; + } + const statesMatch = before.match( + new RegExp(`\\bstates\\s*\\(\\s*(['"])(${ENTITY_PARTIAL})$`) + ); + if (!statesMatch) return null; + const partial = statesMatch[2]; + const quote = statesMatch[1]; + const idx = before.lastIndexOf(statesMatch[0]); + const start = idx + statesMatch[0].length - partial.length; + return { start, partial, quote }; +} + +/** + * @returns {{ start: number; partial: string; quote: string | null } | null} + */ +function getIsStateTokenAtCursor(text, caret) { + const before = text.slice(0, caret); + const empty = before.match(/\bis_state\s*\(\s*(['"])\s*$/); + if (empty) { + return { start: caret, partial: '', quote: empty[1] }; + } + const m = before.match( + new RegExp(`\\bis_state\\s*\\(\\s*(['"])(${ENTITY_PARTIAL})$`) + ); + if (!m) return null; + const partial = m[2]; + const quote = m[1]; + const idx = before.lastIndexOf(m[0]); + const start = idx + m[0].length - partial.length; + return { start, partial, quote }; +} + +/** + * Cursor inside a plot/YAML `entity` value (plain id — not a Jinja template). + * @returns {{ start: number; partial: string; quote: null; entityKeyContext: true } | null} + */ +function getEntityKeyValueToken(text, caret) { + if (caret < 0 || caret > text.length) return null; + const local = text.slice(Math.max(0, caret - 160), caret); + if (local.includes('{{') || local.includes('{%')) return null; + + let end = caret; + if (end > 0 && text[end - 1] === '"') end -= 1; + + let i = end - 1; + while (i >= 0 && /[a-zA-Z0-9_.]/.test(text[i])) i -= 1; + const partial = text.slice(i + 1, end); + const head = text.slice(0, i + 1); + + if (/(?:^|[\r\n])[ \t]*(?:-\s+)?entity\s*:\s*$/m.test(head)) { + return { + start: i + 1, + partial, + quote: null, + entityKeyContext: true, + }; + } + + if (/"entity"\s*:\s*"$/.test(head)) { + return { + start: i + 1, + partial, + quote: null, + entityKeyContext: true, + }; + } + + return null; +} + +/** + * @returns {{ start: number; partial: string; quote: string | null } | null} + */ +export function getEntityTokenAtCursor(text, caret) { + if (getEntityKeyValueToken(text, caret)) { + return null; + } + + if (isYamlPlainValueContext(text, caret)) { + return null; + } + const statesToken = getStatesTokenAtCursor(text, caret); + if (statesToken) return statesToken; + const isStateToken = getIsStateTokenAtCursor(text, caret); + if (isStateToken) return isStateToken; + let i = caret - 1; + while (i >= 0 && /[a-z0-9_.]/i.test(text[i])) { + i -= 1; + } + const start = i + 1; + const partial = text.slice(start, caret); + if (!partial || !/^[a-z][a-z0-9_]*(?:\.[a-z0-9_.]*)?$/i.test(partial)) { + return null; + } + return /** @type {{ start: number; partial: string; quote: null }} */ ({ + start, + partial, + quote: null, + }); +} + +/** + * state_attr('entity_id', 'attribute') completion. + * - If cursor is in first arg: suggest entity_ids + * - If cursor is in second arg: suggest attribute keys for that entity + * + * @returns {{ start:number; partial:string; quote:string; mode:'entity'|'attr'; entityId?:string } | null} + */ +function getStateAttrTokenAtCursor(text, caret) { + const before = text.slice(0, caret); + + // 2nd argument (attribute) in progress. + // Example: state_attr('sensor.weather', 'hum + const attrMatch = before.match( + new RegExp( + `\\bstate_attr\\s*\\(\\s*(['"])(${ENTITY_PARTIAL})\\1\\s*,\\s*(['"])([a-z0-9_ -]*)$`, + 'i' + ) + ); + if (attrMatch) { + const entityId = attrMatch[2]; + const quote = attrMatch[3]; + const partial = attrMatch[4] || ''; + const idx = before.lastIndexOf(attrMatch[0]); + const start = idx + attrMatch[0].length - partial.length; + return { start, partial, quote, mode: 'attr', entityId }; + } + + // 1st argument (entity_id) in progress. + // Example: state_attr('sensor.wea + const entMatch = before.match( + new RegExp(`\\bstate_attr\\s*\\(\\s*(['"])(${ENTITY_PARTIAL})$`, 'i') + ); + if (entMatch) { + const quote = entMatch[1]; + const partial = entMatch[2] || ''; + const idx = before.lastIndexOf(entMatch[0]); + const start = idx + entMatch[0].length - partial.length; + return { start, partial, quote, mode: 'entity' }; + } + + return null; +} + +/** + * @returns {{ start:number; partial:string } | null} + */ +function getColorTokenAtCursor(text, caret) { + const before = text.slice(0, caret); + const lineStart = before.lastIndexOf('\n') + 1; + const line = before.slice(lineStart); + const m = line.match( + /^\s*(?:color|fill|outline|background|legend_color|stroke_fill)\s*:\s*['"]?([a-z0-9_#-]*)$/i + ); + if (!m) return null; + const partial = m[1] || ''; + const start = lineStart + line.length - partial.length; + return { start, partial }; +} + +const TEMPLATE_STARTERS = [ + { label: "states('entity')", insert: "states('')" }, + { label: "state_attr('entity', 'attr')", insert: "state_attr('', '')" }, + { label: "is_state('entity', 'on')", insert: "is_state('', 'on')" }, +]; + +/** + * @returns {{ start: number; partial: string; mode: 'template' } | null} + */ +function getTemplateStarterToken(text, caret) { + const before = text.slice(0, caret); + if (/\{\{\s*$/.test(before)) { + return { start: caret, partial: '', mode: 'template' }; + } + const m = before.match(/\{\{\s*([a-z_]*)$/i); + if (!m) return null; + const partial = m[1] || ''; + return { start: caret - partial.length, partial, mode: 'template' }; +} + +/** + * Pixel position of caret at `idx` relative to the field's client box. + * @param {HTMLInputElement | HTMLTextAreaElement} field + * @param {number} idx + * @returns {{ top: number; left: number; lineHeight: number }} + */ +function fieldCaretOffsets(field, idx) { + const win = field.ownerDocument.defaultView; + if (!win) return { top: 0, left: 0, lineHeight: 18 }; + + const doc = field.ownerDocument; + const cs = win.getComputedStyle(field); + const isTextarea = field instanceof HTMLTextAreaElement; + + /** @type {HTMLElement} */ + const div = doc.createElement('div'); + const props = /** @type {const} */ ([ + ['box-sizing', cs.boxSizing], + ['width', `${field.clientWidth}px`], + ['white-space', isTextarea ? 'pre-wrap' : 'pre'], + ['word-wrap', 'break-word'], + ['overflow-wrap', 'break-word'], + ['direction', cs.direction], + ['text-align', cs.textAlign], + ['text-transform', cs.textTransform], + ['text-indent', cs.textIndent], + ['padding-top', cs.paddingTop], + ['padding-right', cs.paddingRight], + ['padding-bottom', cs.paddingBottom], + ['padding-left', cs.paddingLeft], + ['border-top-width', cs.borderTopWidth], + ['border-right-width', cs.borderRightWidth], + ['border-bottom-width', cs.borderBottomWidth], + ['border-left-width', cs.borderLeftWidth], + ['font-family', cs.fontFamily], + ['font-weight', cs.fontWeight], + ['font-style', cs.fontStyle], + ['letter-spacing', cs.letterSpacing], + ['tab-size', cs.tabSize], + ['word-spacing', cs.wordSpacing], + ['line-height', cs.lineHeight], + ['font-size', cs.fontSize], + ['overflow', 'hidden'], + ['visibility', 'hidden'], + ['position', 'absolute'], + ['top', '-5000px'], + ['left', '0'], + ]); + for (let i = 0; i < props.length; i += 1) { + const [k, v] = props[i]; + div.style.setProperty(k, String(v)); + } + + div.textContent = field.value.slice(0, idx); + const span = doc.createElement('span'); + span.textContent = field.value.slice(idx) || '\u200b'; + div.appendChild(span); + + doc.body.appendChild(div); + const top = span.offsetTop; + const left = span.offsetLeft; + const lhRaw = cs.lineHeight; + const lhPx = + lhRaw.endsWith('px') ? parseFloat(lhRaw) : parseFloat(cs.fontSize) * 1.45; + const lineHeight = Number.isFinite(lhPx) && lhPx > 0 ? lhPx : 18; + doc.body.removeChild(div); + + return { top, left, lineHeight }; +} + +/** + * @param {any} hass + * @param {string} partial + * @param {number} limit + */ +export function filterEntityIds(hass, partial, limit = 40) { + const keys = hass?.states ? Object.keys(hass.states) : []; + const pl = partial.toLowerCase(); + const scored = keys + .filter((k) => k.toLowerCase().startsWith(pl) || k.toLowerCase().includes(pl)) + .sort((a, b) => { + const al = a.toLowerCase().startsWith(pl); + const bl = b.toLowerCase().startsWith(pl); + if (al !== bl) return al ? -1 : 1; + return a.localeCompare(b); + }); + return scored.slice(0, limit); +} + +/** @param {any} hass @param {string} entityId */ +function formatEntityState(hass, entityId) { + const st = hass?.states?.[entityId]; + if (!st || st.state == null) return ''; + const s = String(st.state); + if (s === 'unknown' || s === 'unavailable') return ''; + const unit = st.attributes?.unit_of_measurement; + return unit ? `${s} ${unit}` : s; +} + +/** + * @param {any} hass + * @param {string} partial + * @param {number} limit + * @returns {Array<{ insert: string; label: string }>} + */ +function filterEntitySuggestions(hass, partial, limit = 40) { + return filterEntityIds(hass, partial, limit).map((id) => ({ + insert: id, + label: id, + value: formatEntityState(hass, id), + })); +} + +/** + * Replace placeholder / missing plot entities with the best available HA entity. + * @param {any} hass + * @param {unknown} data + * @returns {unknown[] | null} + */ +export function resolvePlotDataEntities(hass, data) { + if (!Array.isArray(data)) return null; + let changed = false; + const out = data.map((row) => { + if (!row || typeof row !== 'object' || Array.isArray(row)) return row; + const rec = /** @type {Record} */ ({ ...row }); + const eid = String(rec.entity || '').trim(); + if (eid && hass?.states?.[eid]) return row; + const partial = eid.replace(/\.$/, '') || 'sensor'; + const picks = filterEntityIds(hass, partial, 50); + const resolved = + picks.find((id) => id === 'sensor.temperature') || + picks.find((id) => id.startsWith('sensor.')) || + picks[0]; + if (!resolved || resolved === eid) return row; + changed = true; + return { ...rec, entity: resolved }; + }); + return changed ? out : null; +} + +/** @param {unknown} value @param {number} [maxLen] */ +function formatAttrPreview(value, maxLen = 48) { + if (value == null) return ''; + let s = typeof value === 'object' ? JSON.stringify(value) : String(value); + s = s.replace(/\s+/g, ' ').trim(); + if (s.length > maxLen) s = `${s.slice(0, maxLen - 1)}…`; + return s; +} + +/** + * @param {any} hass + * @param {string} entityId + * @param {string} partial + * @param {number} limit + * @returns {Array<{ insert: string; label: string }>} + */ +function filterAttributeSuggestions(hass, entityId, partial, limit = 60) { + const attrs = hass?.states?.[entityId]?.attributes; + if (!attrs || typeof attrs !== 'object') return []; + const keys = Object.keys(attrs); + const pl = String(partial || '').toLowerCase(); + const scored = keys + .filter((k) => k.toLowerCase().startsWith(pl) || k.toLowerCase().includes(pl)) + .sort((a, b) => { + const al = a.toLowerCase().startsWith(pl); + const bl = b.toLowerCase().startsWith(pl); + if (al !== bl) return al ? -1 : 1; + return a.localeCompare(b); + }) + .slice(0, limit); + return scored.map((key) => ({ + insert: key, + label: key, + value: formatAttrPreview(attrs[key]), + })); +} + +/** + * @param {string[]} colors + * @param {string} partial + */ +function filterColors(colors, partial) { + const pl = String(partial || '').toLowerCase(); + return colors + .filter((c) => c.toLowerCase().startsWith(pl) || c.toLowerCase().includes(pl)) + .slice(0, 20); +} + +/** + * @param {HTMLInputElement | HTMLTextAreaElement} field + * @param {number} start + * @param {number} end + * @param {string} insert + */ +export function replaceRange(field, start, end, insert) { + const v = field.value; + const safeStart = Math.max(0, Math.min(start, v.length)); + const safeEnd = Math.max(safeStart, Math.min(end, v.length)); + field.value = v.slice(0, safeStart) + insert + v.slice(safeEnd); + const np = safeStart + insert.length; + field.selectionStart = field.selectionEnd = np; + field.focus(); + field.dispatchEvent(new Event('input', { bubbles: true })); + field.dispatchEvent(new Event('change', { bubbles: true })); +} + +/** + * Resolve the exact [start, end) range to replace for an autocomplete token. + * @param {string} text + * @param {{ start: number; partial?: string; end?: number; caretAtRefresh?: number }} token + */ +function resolveReplaceRange(text, token) { + const partial = String(token.partial || ''); + const caretHint = + typeof token.caretAtRefresh === 'number' + ? token.caretAtRefresh + : typeof token.end === 'number' + ? token.end + : text.length; + + if (token.entityKeyContext) { + const start = Number(token.start); + if (partial.length > 0) { + return { start, end: start + partial.length }; + } + return { start, end: Math.max(start, caretHint) }; + } + + if (!partial.length) { + const start = Math.max(0, Math.min(Number(token.start), text.length)); + const end = Math.max(start, Math.min(caretHint, text.length)); + return { start, end }; + } + + const storedStart = Number(token.start); + if ( + storedStart >= 0 && + text.slice(storedStart, storedStart + partial.length).toLowerCase() === partial.toLowerCase() + ) { + return { start: storedStart, end: storedStart + partial.length }; + } + + const endFromCaret = Math.max(0, Math.min(caretHint, text.length)); + const startFromCaret = endFromCaret - partial.length; + if ( + startFromCaret >= 0 && + text.slice(startFromCaret, endFromCaret).toLowerCase() === partial.toLowerCase() + ) { + return { start: startFromCaret, end: endFromCaret }; + } + + let end = endFromCaret; + let start = storedStart >= 0 ? storedStart : end; + while (start > 0 && /[a-z0-9_.]/i.test(text[start - 1])) start -= 1; + while (end < text.length && /[a-z0-9_.]/i.test(text[end])) end += 1; + return { start, end }; +} + +/** + * @param {HTMLElement} wrap + * @param {HTMLInputElement | HTMLTextAreaElement} field + * @param {() => any} getHass + * @param {{ + * getColorSuggestions?: () => string[]; + * wrapAsStates?: boolean; + * colorField?: boolean; + * }} [opts] + */ +export function createFieldAutocomplete(wrap, field, getHass, opts = {}) { + wrap.style.position = 'relative'; + const list = document.createElement('ul'); + list.className = 'od-autocomplete'; + list.hidden = true; + list.setAttribute('role', 'listbox'); + wrap.appendChild(list); + list.addEventListener('mousedown', (e) => e.preventDefault()); + + let active = -1; + let token = /** @type {Record | null} */ (null); + let suggestions = /** @type {Array<{ insert: string; label: string; value?: string }>} */ ([]); + let picking = false; + + function hide() { + list.hidden = true; + list.replaceChildren(); + active = -1; + token = null; + suggestions = []; + } + + function positionList() { + const anchor = token + ? Math.min(Number(token.start), field.value.length) + : field.selectionStart ?? 0; + + let cTop = 0; + let cLeft = 0; + let lineHeight = 18; + try { + ({ top: cTop, left: cLeft, lineHeight } = fieldCaretOffsets(field, anchor)); + } catch { + hide(); + return; + } + + let x = field.offsetLeft + field.clientLeft + cLeft - field.scrollLeft; + const lineTop = field.offsetTop + field.clientTop + cTop - field.scrollTop; + let y = lineTop + lineHeight; + + const pad = 4; + const maxWAvail = wrap.clientWidth - pad * 2; + const preferredW = Math.min(460, Math.max(200, field.clientWidth)); + let listW = Math.min(preferredW, Math.max(160, wrap.clientWidth - x - pad)); + listW = Math.max(140, Math.min(listW, maxWAvail)); + + if (x + listW + pad > wrap.clientWidth) { + x = Math.max(pad, wrap.clientWidth - listW - pad); + } + x = Math.max(pad, x); + + const estH = Math.min( + Math.max(list.scrollHeight || 0, suggestions.length * 34 + 20), + 280 + ); + if (y + estH + pad > wrap.clientHeight) { + const aboveY = lineTop - estH - 6; + if (aboveY >= pad) y = aboveY; + } + + list.style.left = `${x}px`; + list.style.top = `${Math.max(pad, y)}px`; + list.style.width = `${listW}px`; + } + + function renderList(items) { + list.replaceChildren(); + items.forEach((item, idx) => { + const li = document.createElement('li'); + const idSpan = document.createElement('span'); + idSpan.className = 'od-ac-id'; + idSpan.textContent = item.label; + li.appendChild(idSpan); + if (item.value) { + const valSpan = document.createElement('span'); + valSpan.className = 'od-ac-val'; + valSpan.textContent = item.value; + li.appendChild(valSpan); + } + li.dataset.idx = String(idx); + li.setAttribute('role', 'option'); + if (idx === active) li.classList.add('active'); + li.addEventListener('mousedown', (e) => { + e.preventDefault(); + pick(idx); + }); + list.appendChild(li); + }); + list.hidden = items.length === 0; + positionList(); + requestAnimationFrame(() => { + if (!list.hidden && suggestions.length > 0) positionList(); + }); + } + + function buildEntityInsert(id) { + if (opts.wrapAsStates) return `{{ states('${id}') }}`; + return id; + } + + function pick(idx) { + if (!token || idx < 0 || idx >= suggestions.length) return; + const snap = { ...token }; + const choice = suggestions[idx].insert; + field.focus({ preventScroll: true }); + const { start, end } = resolveReplaceRange(field.value, snap); + picking = true; + replaceRange(field, start, end, choice); + picking = false; + hide(); + } + + /** @param {Record} t */ + function stampToken(t) { + const caret = field.selectionStart ?? field.value.length; + const partial = String(t.partial || ''); + const storedStart = Number(t.start); + let end = caret; + if ( + partial.length > 0 && + storedStart >= 0 && + field.value.slice(storedStart, storedStart + partial.length).toLowerCase() === + partial.toLowerCase() + ) { + end = storedStart + partial.length; + } + return { ...t, end, caretAtRefresh: caret }; + } + + function refresh() { + const hass = getHass(); + const caret = field.selectionStart ?? field.value.length; + const text = field.value; + + const tpl = getTemplateStarterToken(text, caret); + if (tpl) { + token = stampToken(tpl); + const partial = String(tpl.partial || '').toLowerCase(); + suggestions = TEMPLATE_STARTERS.filter( + (s) => + !partial || + s.label.toLowerCase().includes(partial) || + s.insert.toLowerCase().startsWith(partial) + ).map((s) => ({ + insert: partial ? `${s.insert} }}` : ` ${s.insert} }}`, + label: s.label, + value: 'Jinja', + })); + if (suggestions.length === 0) { + hide(); + return; + } + active = 0; + renderList(suggestions); + return; + } + + if (opts.colorField && !text.includes('{{')) { + const before = text.slice(0, caret); + const m = before.match(/([a-z0-9_#-]*)$/i); + if (m) { + token = stampToken({ start: caret - m[1].length, partial: m[1], quote: null }); + const colors = + typeof opts.getColorSuggestions === 'function' + ? opts.getColorSuggestions() + : ['white', 'black']; + suggestions = filterColors(colors, m[1]).map((c) => ({ + insert: c, + label: c, + value: '', + })); + if (suggestions.length > 0) { + active = 0; + renderList(suggestions); + return; + } + } + } + + const colorToken = getColorTokenAtCursor(text, caret); + if (colorToken) { + token = stampToken({ + start: colorToken.start, + partial: colorToken.partial, + quote: null, + }); + const colors = + typeof opts.getColorSuggestions === 'function' + ? opts.getColorSuggestions() + : ['white', 'black']; + suggestions = filterColors(colors, colorToken.partial).map((c) => ({ + insert: c, + label: c, + value: '', + })); + if (suggestions.length === 0) { + hide(); + return; + } + active = 0; + renderList(suggestions); + return; + } + + const entityKey = getEntityKeyValueToken(text, caret); + if (entityKey) { + token = stampToken(entityKey); + suggestions = filterEntitySuggestions(hass, entityKey.partial).map((s) => ({ + ...s, + insert: s.insert, + })); + if (suggestions.length === 0) { + hide(); + return; + } + active = 0; + renderList(suggestions); + return; + } + + const sa = getStateAttrTokenAtCursor(text, caret); + if (sa) { + token = stampToken(sa); + if (sa.mode === 'attr' && sa.entityId) { + suggestions = filterAttributeSuggestions(hass, sa.entityId, sa.partial); + } else { + suggestions = filterEntitySuggestions(hass, sa.partial).map((s) => ({ + ...s, + insert: sa.quote != null ? s.insert : buildEntityInsert(s.insert), + })); + } + if (suggestions.length === 0) { + hide(); + return; + } + active = 0; + renderList(suggestions); + return; + } + + const t = getEntityTokenAtCursor(text, caret); + const allowEmpty = t && t.quote != null; + if (!t || (t.partial.length < 1 && !allowEmpty)) { + hide(); + return; + } + token = stampToken(/** @type {any} */ (t)); + suggestions = filterEntitySuggestions(hass, t.partial).map((s) => ({ + ...s, + insert: t.quote != null ? s.insert : buildEntityInsert(s.insert), + })); + if (suggestions.length === 0) { + hide(); + return; + } + active = 0; + renderList(suggestions); + } + + field.addEventListener('blur', () => { + setTimeout(hide, 150); + }); + field.addEventListener('scroll', () => { + if (!list.hidden) positionList(); + }); + + field.addEventListener('keydown', (e) => { + if (list.hidden) { + if ((e.ctrlKey || e.metaKey) && e.key === ' ') { + e.preventDefault(); + refresh(); + } + return; + } + if (e.key === 'Escape') { + e.preventDefault(); + hide(); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + active = Math.min(active + 1, suggestions.length - 1); + renderList(suggestions); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + active = Math.max(active - 1, 0); + renderList(suggestions); + } else if (e.key === 'Enter' || e.key === 'Tab') { + e.preventDefault(); + pick(active); + } + }); + + field.addEventListener('input', () => { + if (picking) return; + refresh(); + }); + field.addEventListener('click', refresh); + field.addEventListener('keyup', (e) => { + if ( + e.key === 'ArrowLeft' || + e.key === 'ArrowRight' || + e.key === 'ArrowUp' || + e.key === 'ArrowDown' || + e.key === 'Home' || + e.key === 'End' + ) { + refresh(); + } + }); + + return { refresh, hide, maybeOpen: refresh }; +} + +/** + * @param {HTMLElement} wrap + * @param {HTMLTextAreaElement} ta + * @param {() => any} getHass + */ +export function setupEntityAutocomplete(wrap, ta, getHass, opts = {}) { + return createFieldAutocomplete(wrap, ta, getHass, opts); +} + +/** + * Attach autocomplete to all inspector text fields (re-created each selection). + * @param {HTMLElement} panel + * @param {() => any} getHass + */ +export function setupInspectorAutocomplete(panel, getHass, opts = {}) { + const controllers = []; + panel.querySelectorAll('.od-inspector-field-wrap').forEach((wrap) => { + const field = wrap.querySelector('input, textarea'); + if (!field || !(field instanceof HTMLInputElement || field instanceof HTMLTextAreaElement)) { + return; + } + controllers.push( + createFieldAutocomplete( + /** @type {HTMLElement} */ (wrap), + field, + getHass, + { + getColorSuggestions: opts.getColorSuggestions, + wrapAsStates: field.dataset.odAcEntity === '1', + colorField: field.dataset.odAcColors === '1', + } + ) + ); + }); + return { + refresh: () => controllers.forEach((c) => c.refresh()), + hide: () => controllers.forEach((c) => c.hide()), + }; +} diff --git a/custom_components/opendisplay/designer/frontend/app/export.js b/custom_components/opendisplay/designer/frontend/app/export.js new file mode 100644 index 0000000..98c52f4 --- /dev/null +++ b/custom_components/opendisplay/designer/frontend/app/export.js @@ -0,0 +1,94 @@ +import { + formatPayloadYamlBlock, + formatPayloadYamlList, + buildServiceCallSnippet, +} from './yaml_util.js'; +import { buildBlueprintSnippet } from './designer_extras.js'; +import { VIRTUAL_DEVICE_ID } from './dimensions.js'; + +/** + * @param {(sel: string) => HTMLElement} $ + */ +export function readServiceData($, dryRun) { + return { + background: /** @type {HTMLSelectElement} */ ($('#od-bg')).value, + rotate: Number(/** @type {HTMLSelectElement} */ ($('#od-rot')).value), + dither: /** @type {HTMLSelectElement} */ ($('#od-dither')).value, + refresh_type: /** @type {HTMLSelectElement} */ ($('#od-refresh')).value, + 'dry-run': dryRun, + }; +} + +/** @param {(sel: string) => HTMLElement} $ */ +export function readExportFields($) { + return { + background: /** @type {HTMLSelectElement} */ ($('#od-bg')).value, + rotate: Number(/** @type {HTMLSelectElement} */ ($('#od-rot')).value), + dither: /** @type {HTMLSelectElement} */ ($('#od-dither')).value, + refresh_type: /** @type {HTMLSelectElement} */ ($('#od-refresh')).value, + dry_run: false, + }; +} + +/** + * @param {{ + * $: (sel: string) => HTMLElement; + * effectiveDeviceId: () => string; + * silentParsePayload: () => unknown[] | null; + * }} deps + */ +export function rebuildExportSnippet(deps) { + const { $, effectiveDeviceId, silentParsePayload } = deps; + const devId = effectiveDeviceId(); + const parsed = silentParsePayload(); + const payloadBlock = formatPayloadYamlBlock(Array.isArray(parsed) ? parsed : []); + const exportEl = /** @type {HTMLPreElement} */ ($('#od-export')); + if (devId === VIRTUAL_DEVICE_ID) { + exportEl.textContent = + 'Virtual device selected: export/send is disabled (no Home Assistant target device_id).'; + return; + } + exportEl.textContent = devId + ? buildServiceCallSnippet(devId, readExportFields($), payloadBlock) + : 'Pick a device id to export.'; +} + +/** + * @param {{ + * $: (sel: string) => HTMLElement; + * effectiveDeviceId: () => string; + * silentParsePayload: () => unknown[] | null; + * showToast: (msg: string, isErr: boolean) => void; + * }} deps + */ +export function copyExportSnippet(deps) { + rebuildExportSnippet(deps); + const text = /** @type {HTMLPreElement} */ (deps.$('#od-export')).textContent || ''; + navigator.clipboard.writeText(text).then( + () => deps.showToast('Copied', false), + () => deps.showToast('Copy failed', true) + ); +} + +/** + * @param {{ + * silentParsePayload: () => unknown[] | null; + * showToast: (msg: string, isErr: boolean) => void; + * }} deps + */ +export function copyBlueprintSnippet(deps) { + const parsed = deps.silentParsePayload(); + if (!parsed) { + deps.showToast('Fix YAML first', true); + return; + } + const payloadIndented = formatPayloadYamlList(parsed) + .split('\n') + .map((l) => ` ${l}`) + .join('\n'); + const text = buildBlueprintSnippet(payloadIndented); + navigator.clipboard.writeText(text).then( + () => deps.showToast('Blueprint copied', false), + () => deps.showToast('Copy failed', true) + ); +} diff --git a/custom_components/opendisplay/designer/frontend/app/main.js b/custom_components/opendisplay/designer/frontend/app/main.js new file mode 100644 index 0000000..f715551 --- /dev/null +++ b/custom_components/opendisplay/designer/frontend/app/main.js @@ -0,0 +1,808 @@ +import yaml from '../vendor/js-yaml.mjs'; +import { formatPayloadYamlList, normalizePayloadYamlInput } from './yaml_util.js'; +import { setupYamlGutter } from './yaml_gutter.js'; +import { + renderInspector, + applyInspectorFieldValue, + setupWorkspaceTabs, + setupShortcutsModal, + setupDiffModal, +} from './designer_extras.js'; +import { + setupEntityAutocomplete, + setupInspectorAutocomplete, + resolvePlotDataEntities, +} from './entity_autocomplete.js'; +import { + VIRTUAL_DEVICE_ID, + COLOR_FALLBACK, + listOpenDisplayDevices, + parsePositiveInt, + resolveTagPx, + availableDisplayColors, + colorChipHex, + dimensionsFromImageEntity, +} from './dimensions.js'; +import { rebuildExportSnippet, copyExportSnippet, copyBlueprintSnippet } from './export.js'; +import { createPreviewRuntime } from './preview.js'; +import { + KNOWN_ELEMENT_TYPES, + setupPaletteChips, + attachSketchEditor, +} from './sketch_edit.js'; + +/** @typedef {{ setHass: (h:any)=>void; destroy: ()=>void }} MountResult */ + +const HISTORY_LIMIT = 80; + +/** @param {unknown} err */ +function errMsg(err) { + if (err && typeof err === 'object') { + const message = Reflect.get(err, 'message'); + const code = Reflect.get(err, 'code'); + const body = Reflect.get(err, 'body'); + let bodyStr = ''; + if (body && typeof body === 'object' && body.message) { + bodyStr = String(body.message); + } + return ( + [typeof message === 'string' ? message : '', bodyStr].filter(Boolean).join(' — ') || + (typeof code === 'string' ? code : 'Error') + ); + } + return String(err); +} + +function parsePayloadYaml(text) { + const normalized = normalizePayloadYamlInput(text); + const doc = yaml.load(normalized || '[]'); + if (!Array.isArray(doc)) { + throw new Error('Payload must be a YAML list (array) of draw elements'); + } + return doc; +} + +/** + * @param {HTMLElement} host + * @param {any} initialHass + * @returns {MountResult} + */ +export function mountDesigner(host, initialHass) { + const shadow = host.shadowRoot || host.attachShadow({ mode: 'open' }); + shadow.replaceChildren(); + + const root = document.createElement('div'); + root.className = 'od-root'; + root.innerHTML = ` +
+
+ +
+
+ + + + +
+
+ +
+
+

Payload (YAML)

+
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+
+
+

Preview

+
+
+ + + + +
+
+
+ Add +
+
+ Select device; auto-preview runs after edits when enabled. +
Sketch
+
HA render
+ + +
+ +
+
+ +
+
+
+ Service options & export +
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+

+        
+
+
+ + + `; + + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = new URL('./styles.css', import.meta.url).href; + shadow.append(link, root); + + /** @type {any} */ + let hass = initialHass; + const $ = /** @type {(s:string)=>HTMLElement} */ (sel) => + /** @type {HTMLElement} */ (shadow.querySelector(sel)); + + const previewFrame = /** @type {HTMLElement} */ ($('#od-preview-frame')); + const sketchCanvas = /** @type {HTMLCanvasElement} */ ($('#od-sketch')); + const previewImgEl = /** @type {HTMLImageElement} */ ($('#od-preview-img')); + const colorSwatchesEl = /** @type {HTMLElement} */ ($('#od-color-swatches')); + const virtualSizeWrap = /** @type {HTMLElement} */ ($('#od-virtual-size')); + const virtualWInput = /** @type {HTMLInputElement} */ ($('#od-virtual-w')); + const virtualHInput = /** @type {HTMLInputElement} */ ($('#od-virtual-h')); + const sendBtn = /** @type {HTMLButtonElement} */ ($('#od-send')); + const taPayload = /** @type {HTMLTextAreaElement} */ ($('#od-payload')); + const editorShell = /** @type {HTMLElement} */ ($('#od-editor-shell')); + + setupPaletteChips(/** @type {HTMLElement} */ ($('#od-palette-root'))); + + taPayload.value = `- type: text + value: Hello World! + x: 0 + y: 0 + size: 40 + color: black +`; + + let lastValidPayload = /** @type {unknown[] | null} */ (null); + let lastParseError = /** @type {string | null} */ (null); + let cachedAvailableColors = [...COLOR_FALLBACK]; + let undoStack = /** @type {string[]} */ ([]); + let redoStack = /** @type {string[]} */ ([]); + let suppressHistoryCapture = false; + /** @type {ReturnType | null} */ + let inspectorAc = null; + + const state = { + sketchDebounceTimer: /** @type {ReturnType | null} */ (null), + haPreviewDebounceTimer: /** @type {ReturnType | null} */ (null), + haGeneration: 0, + haDryApplyTicket: 0, + yamlPreviewBump: 0, + pendingAutoHaBumpTarget: /** @type {number | null} */ (null), + haBmpReflectsYamlBump: /** @type {number | null} */ (null), + selectedItemIdx: -1, + lastHaPreviewPayloadYaml: /** @type {string | null} */ (null), + sketchEdit: /** @type {null | { + pointerId: number; + mode: 'move' | 'resize'; + idx: number; + lastTx: number; + lastTy: number; + handle?: { id: string; kind: string; x: number; y: number; vertexIndex?: number } | null; + }} */ (null), + }; + + function getHass() { + return hass; + } + + function effectiveDeviceId() { + return /** @type {HTMLSelectElement} */ ($('#od-device')).value; + } + + function isVirtualDeviceSelected() { + return effectiveDeviceId() === VIRTUAL_DEVICE_ID; + } + + function getTagPx() { + const meta = resolveTagPx(hass, effectiveDeviceId(), previewImgEl, { + w: parsePositiveInt(virtualWInput.value), + h: parsePositiveInt(virtualHInput.value), + }); + return { tagW: meta.tagW, tagH: meta.tagH }; + } + + function showToast(msg, isErr) { + const t = document.createElement('div'); + t.className = `od-toast${isErr ? ' err' : ''}`; + t.textContent = msg; + shadow.appendChild(t); + setTimeout(() => t.remove(), 5000); + } + + function refreshBackgroundSelect() { + const sel = /** @type {HTMLSelectElement} */ ($('#od-bg')); + const cur = sel.value; + const colors = availableDisplayColors(hass, effectiveDeviceId()); + sel.replaceChildren(); + for (const c of colors) { + const opt = document.createElement('option'); + opt.value = c; + opt.textContent = c; + sel.appendChild(opt); + } + if (colors.includes(cur)) sel.value = cur; + else if (colors.includes('white')) sel.value = 'white'; + else if (colors.length) sel.value = colors[0]; + } + + function refreshColorSwatches() { + cachedAvailableColors = availableDisplayColors(hass, effectiveDeviceId()); + refreshBackgroundSelect(); + colorSwatchesEl.replaceChildren(); + const devId = effectiveDeviceId(); + for (const c of cachedAvailableColors) { + const chip = document.createElement('span'); + chip.className = 'od-color-chip'; + chip.title = c; + chip.setAttribute('aria-label', c); + chip.style.background = colorChipHex(c, hass, devId); + if (c === 'white') chip.style.borderColor = 'rgba(0,0,0,0.35)'; + colorSwatchesEl.appendChild(chip); + } + if (state.selectedItemIdx >= 0) updateSelectionUi(); + } + + function refreshDeviceSelect() { + const sel = /** @type {HTMLSelectElement} */ ($('#od-device')); + const devices = listOpenDisplayDevices(hass); + const cur = sel.value; + sel.innerHTML = ''; + if (devices.length === 0) { + const opt = document.createElement('option'); + opt.value = ''; + opt.textContent = 'No OpenDisplay devices in the registry'; + sel.appendChild(opt); + const virt = document.createElement('option'); + virt.value = VIRTUAL_DEVICE_ID; + virt.textContent = 'Virtual device (local sketch)'; + sel.appendChild(virt); + return; + } + for (const d of devices) { + const opt = document.createElement('option'); + opt.value = d.id; + opt.textContent = d.name; + sel.appendChild(opt); + } + if (cur && devices.some((d) => d.id === cur)) sel.value = cur; + } + + function refreshDeviceUiMode() { + const isVirtual = isVirtualDeviceSelected(); + virtualWInput.disabled = !isVirtual; + virtualHInput.disabled = !isVirtual; + virtualSizeWrap.classList.toggle('is-locked', !isVirtual); + sendBtn.hidden = isVirtual; + if (isVirtual) $('#od-ha-status').textContent = 'Virtual mode: local sketch only.'; + refreshColorSwatches(); + } + + function syncDimensionInputsFromSelectedDevice() { + const devId = effectiveDeviceId(); + if (!devId || devId === VIRTUAL_DEVICE_ID) return; + const dims = dimensionsFromImageEntity(hass, devId); + if (!dims) return; + virtualWInput.value = String(dims.tagW); + virtualHInput.value = String(dims.tagH); + } + + function silentParsePayload() { + try { + return parsePayloadYaml(taPayload.value); + } catch { + return null; + } + } + + function collectPayloadDiagnostics(parsed) { + /** @type {{ level:'error'|'warn'; message:string }[]} */ + const out = []; + if (!Array.isArray(parsed)) { + out.push({ level: 'error', message: 'Payload is not a YAML list.' }); + return out; + } + for (let i = 0; i < parsed.length; i += 1) { + const item = parsed[i]; + if (!item || typeof item !== 'object' || Array.isArray(item)) { + out.push({ level: 'error', message: `Item ${i + 1}: must be an object.` }); + continue; + } + const rec = /** @type {Record} */ (item); + const type = String(rec.type || '').trim().toLowerCase(); + if (!type) { + out.push({ level: 'error', message: `Item ${i + 1}: missing type.` }); + continue; + } + if (!KNOWN_ELEMENT_TYPES.has(type)) { + out.push({ + level: 'warn', + message: `Item ${i + 1}: unknown type "${type}" (preview fallback only).`, + }); + } + if ((type === 'text' || type === 'multiline') && !String(rec.value ?? '').trim()) { + out.push({ level: 'warn', message: `Item ${i + 1}: ${type} has empty value.` }); + } + } + return out; + } + + function renderDiagnostics(diags) { + const box = /** @type {HTMLElement} */ ($('#od-diagnostics')); + box.replaceChildren(); + if (!Array.isArray(diags) || diags.length === 0) { + box.hidden = true; + yamlGutter?.refresh(); + return; + } + box.hidden = false; + const title = document.createElement('strong'); + title.textContent = `Diagnostics (${diags.length})`; + box.appendChild(title); + for (const d of diags) { + const line = document.createElement('div'); + line.className = `od-diag-item od-diag-${d.level === 'warn' ? 'warn' : 'err'}`; + line.textContent = d.message; + box.appendChild(line); + } + yamlGutter?.refresh(); + } + + function validatePayload() { + const status = /** @type {HTMLElement} */ ($('#od-parse-status')); + const raw = taPayload.value.replace(/\r\n/g, '\n'); + const normalized = normalizePayloadYamlInput(raw); + if (normalized !== raw.trim()) { + suppressHistoryCapture = true; + taPayload.value = normalized ? `${normalized}\n` : ''; + suppressHistoryCapture = false; + } + try { + const arr = parsePayloadYaml(taPayload.value); + lastValidPayload = arr; + lastParseError = null; + status.hidden = true; + status.textContent = ''; + renderDiagnostics(collectPayloadDiagnostics(arr)); + yamlGutter?.refresh(); + return true; + } catch (e) { + lastValidPayload = null; + lastParseError = errMsg(e); + status.textContent = lastParseError || 'Invalid YAML'; + status.hidden = false; + renderDiagnostics([{ level: 'error', message: status.textContent || 'Invalid YAML' }]); + yamlGutter?.refresh(); + return false; + } + } + + function updateUndoRedoUi() { + /** @type {HTMLButtonElement} */ ($('#od-undo')).disabled = undoStack.length <= 1; + /** @type {HTMLButtonElement} */ ($('#od-redo')).disabled = redoStack.length === 0; + } + + function pushUndoSnapshot(rawYaml) { + if (suppressHistoryCapture) return; + const current = String(rawYaml ?? ''); + if (undoStack.length > 0 && undoStack[undoStack.length - 1] === current) return; + undoStack.push(current); + if (undoStack.length > HISTORY_LIMIT) { + undoStack = undoStack.slice(undoStack.length - HISTORY_LIMIT); + } + redoStack = []; + updateUndoRedoUi(); + } + + function applyYamlTextSnapshot(yamlText, reason = '') { + suppressHistoryCapture = true; + taPayload.value = yamlText; + suppressHistoryCapture = false; + lastValidPayload = null; + lastParseError = null; + validatePayload(); + rebuildExportSnippet(exportDeps); + preview.scheduleRedrawSketch(); + preview.scheduleDebouncedHaPreview(); + if (reason) $('#od-ha-status').textContent = reason; + } + + function undoPayloadEdit() { + if (undoStack.length <= 1) return; + const current = undoStack.pop(); + if (current != null) redoStack.push(current); + applyYamlTextSnapshot(undoStack[undoStack.length - 1] || '', 'Undo applied.'); + updateUndoRedoUi(); + } + + function redoPayloadEdit() { + if (redoStack.length === 0) return; + const next = redoStack.pop(); + if (next == null) return; + undoStack.push(next); + applyYamlTextSnapshot(next, 'Redo applied.'); + updateUndoRedoUi(); + } + + function applyEditedPayload(parsed) { + taPayload.value = formatPayloadYamlList(parsed); + lastValidPayload = null; + lastParseError = null; + /** @type {HTMLElement} */ ($('#od-parse-status')).hidden = true; + $('#od-parse-status').textContent = ''; + preview.bumpYamlPreviewGenOnly(); + pushUndoSnapshot(taPayload.value); + updateUndoRedoUi(); + rebuildExportSnippet(exportDeps); + preview.scheduleRedrawSketch(); + preview.scheduleDebouncedHaPreview(); + renderDiagnostics(collectPayloadDiagnostics(parsed)); + } + + function updateSelectionUi() { + const parsed = silentParsePayload(); + const inspectorEl = /** @type {HTMLElement} */ ($('#od-inspector')); + const item = + parsed && state.selectedItemIdx >= 0 && state.selectedItemIdx < parsed.length + ? /** @type {Record} */ (parsed[state.selectedItemIdx]) + : null; + renderInspector( + inspectorEl, + state.selectedItemIdx, + item, + { availableColors: cachedAvailableColors }, + (idx, key, value) => { + const p = silentParsePayload(); + if (!p || idx < 0 || idx >= p.length) return; + const el = /** @type {Record} */ (p[idx]); + applyInspectorFieldValue(el, key, value); + applyEditedPayload(p); + validatePayload(); + } + ); + inspectorAc?.hide(); + inspectorAc = setupInspectorAutocomplete(inspectorEl, getHass, { + getColorSuggestions: () => cachedAvailableColors, + }); + if (state.selectedItemIdx >= 0) yamlGutter?.scrollToItem(state.selectedItemIdx); + preview.scheduleRedrawSketch(); + } + + const exportDeps = { + $, + effectiveDeviceId, + silentParsePayload, + showToast, + }; + + const preview = createPreviewRuntime({ + shadow, + previewFrame, + previewImgEl, + getTagPx, + getHass, + effectiveDeviceId, + isVirtualDeviceSelected, + silentParsePayload, + lastParseError: () => lastParseError, + lastValidPayload: () => lastValidPayload, + validatePayload, + showToast, + $, + state, + }); + + const sketch = attachSketchEditor({ + sketchCanvas, + previewFrame, + previewImgEl, + getHass, + getTagPx, + silentParsePayload, + applyEditedPayload, + validatePayload, + updateSelectionUi, + showToast, + leaveHaForEdit: preview.leaveHaForEdit, + scheduleRedrawSketch: preview.scheduleRedrawSketch, + state, + }); + + const yamlGutter = setupYamlGutter(editorShell, taPayload, () => ({ + text: taPayload.value, + diags: lastValidPayload ? collectPayloadDiagnostics(lastValidPayload) : [], + parseError: lastParseError, + })); + + setupEntityAutocomplete(wrapForAc(), taPayload, () => hass, { + getColorSuggestions: () => cachedAvailableColors, + }); + + function wrapForAc() { + return /** @type {HTMLElement} */ (shadow.querySelector('.od-editor-wrap')); + } + + setupWorkspaceTabs(root, (tab) => { + if (tab === 'export') /** @type {HTMLDetailsElement} */ ($('.od-export-panel')).open = true; + }); + + const shortcutsModal = setupShortcutsModal(shadow, { + '?': 'Show keyboard shortcuts', + 'Ctrl/Cmd+Z': 'Undo', + 'Ctrl/Cmd+Shift+Z / Ctrl+Y': 'Redo', + 'Ctrl/Cmd+Space': 'Autocomplete — YAML editor & inspector fields', + Escape: 'Clear selection', + }); + + setupDiffModal(shadow, () => taPayload.value, () => state.lastHaPreviewPayloadYaml); + + $('#od-inspector').addEventListener('od-jump-yaml', (ev) => { + const idx = /** @type {CustomEvent} */ (ev).detail?.idx; + if (typeof idx === 'number') yamlGutter?.scrollToItem(idx); + }); + + $('#od-shortcuts-open').addEventListener('click', () => shortcutsModal.open()); + $('#od-blueprint').addEventListener('click', () => copyBlueprintSnippet(exportDeps)); + $('#od-copy').addEventListener('click', () => copyExportSnippet(exportDeps)); + + shadow.querySelectorAll('input[name="od-pvm"]').forEach((el) => { + el.addEventListener('change', () => { + preview.syncPreviewFrameClass(); + preview.redrawSketch(); + }); + }); + + taPayload.addEventListener('input', () => { + if (!suppressHistoryCapture) pushUndoSnapshot(taPayload.value); + updateUndoRedoUi(); + preview.bumpYamlPreviewGenOnly(); + lastValidPayload = null; + lastParseError = null; + validatePayload(); + rebuildExportSnippet(exportDeps); + preview.scheduleDebouncedHaPreview(); + }); + + taPayload.addEventListener('keydown', (e) => { + if (e.key === '?' && !e.ctrlKey && !e.metaKey && !e.altKey) { + shortcutsModal.open(); + e.preventDefault(); + return; + } + const ctrlOrMeta = e.ctrlKey || e.metaKey; + if (ctrlOrMeta && !e.altKey) { + const key = String(e.key || '').toLowerCase(); + if (key === 'z' && !e.shiftKey) { + e.preventDefault(); + undoPayloadEdit(); + return; + } + if ((key === 'z' && e.shiftKey) || key === 'y') { + e.preventDefault(); + redoPayloadEdit(); + return; + } + } + if (e.key === 'Escape') { + sketch.clearSelection(); + preview.scheduleRedrawSketch(); + } + }); + + $('#od-preview').addEventListener('click', () => + void preview.interactiveDrawcustom(true).catch(() => {}) + ); + $('#od-send').addEventListener('click', () => { + if (!window.confirm('Push this image to the tag (real update)?')) return; + void preview.interactiveDrawcustom(false).catch(() => {}); + }); + $('#od-undo').addEventListener('click', undoPayloadEdit); + $('#od-redo').addEventListener('click', redoPayloadEdit); + + previewImgEl.addEventListener('load', () => { + preview.onPreviewImageLoad(); + refreshColorSwatches(); + }); + previewImgEl.addEventListener('error', () => { + state.pendingAutoHaBumpTarget = null; + }); + + $('#od-device').addEventListener('change', () => { + sketch.clearSelection(); + preview.clearHaDryRunPreview(); + refreshDeviceUiMode(); + syncDimensionInputsFromSelectedDevice(); + state.haDryApplyTicket += 1; + refreshColorSwatches(); + rebuildExportSnippet(exportDeps); + preview.scheduleRedrawSketch(); + preview.scheduleDebouncedHaPreview(); + }); + + ['#od-bg', '#od-dither', '#od-refresh'].forEach((id) => { + shadow.querySelector(id)?.addEventListener('change', () => { + rebuildExportSnippet(exportDeps); + preview.scheduleRedrawSketch(); + preview.scheduleDebouncedHaPreview(); + }); + }); + + shadow.querySelector('#od-rot')?.addEventListener('change', () => { + sketch.clearSelection(); + rebuildExportSnippet(exportDeps); + preview.scheduleRedrawSketch(); + preview.scheduleDebouncedHaPreview(); + }); + + [virtualWInput, virtualHInput].forEach((inp) => { + inp.addEventListener('input', () => { + if (!isVirtualDeviceSelected()) return; + refreshColorSwatches(); + preview.scheduleRedrawSketch(); + rebuildExportSnippet(exportDeps); + }); + }); + + const HA_THEME_VARS = [ + '--primary-background-color', + '--primary-text-color', + '--secondary-background-color', + '--secondary-text-color', + '--card-background-color', + '--divider-color', + '--disabled-text-color', + '--error-color', + '--accent-color', + ]; + + function syncHaTheme() { + const src = getComputedStyle(document.documentElement); + for (const name of HA_THEME_VARS) { + const val = src.getPropertyValue(name).trim(); + if (val) host.style.setProperty(name, val); + } + const dark = Boolean( + hass?.themes?.darkMode ?? + hass?.selectedTheme?.dark ?? + document.documentElement.classList.contains('dark-mode') + ); + host.style.colorScheme = dark ? 'dark' : 'light'; + root.style.colorScheme = dark ? 'dark' : 'light'; + root.classList.toggle('od-ha-dark', dark); + root.classList.toggle('od-ha-light', !dark); + } + + function onHass() { + syncHaTheme(); + refreshDeviceSelect(); + refreshDeviceUiMode(); + syncDimensionInputsFromSelectedDevice(); + refreshColorSwatches(); + rebuildExportSnippet(exportDeps); + preview.redrawSketch(); + } + + const setHass = (h) => { + hass = h; + onHass(); + }; + + root.classList.add('od-tab-yaml'); + syncHaTheme(); + setHass(hass); + pushUndoSnapshot(taPayload.value); + updateUndoRedoUi(); + validatePayload(); + yamlGutter?.refresh(); + updateSelectionUi(); + + const previewResizeRo = new ResizeObserver(() => preview.scheduleRedrawSketch()); + previewResizeRo.observe(previewFrame); + + preview.syncPreviewFrameClass(); + preview.refreshPreviewPlaceholderVisibility(); + + requestAnimationFrame(() => { + syncDimensionInputsFromSelectedDevice(); + rebuildExportSnippet(exportDeps); + preview.scheduleDebouncedHaPreview(); + }); + + return { + setHass, + destroy() { + previewResizeRo.disconnect(); + if (state.sketchDebounceTimer) clearTimeout(state.sketchDebounceTimer); + if (state.haPreviewDebounceTimer) clearTimeout(state.haPreviewDebounceTimer); + shadow.replaceChildren(); + }, + }; +} diff --git a/custom_components/opendisplay/designer/frontend/app/preview.js b/custom_components/opendisplay/designer/frontend/app/preview.js new file mode 100644 index 0000000..9f0a672 --- /dev/null +++ b/custom_components/opendisplay/designer/frontend/app/preview.js @@ -0,0 +1,416 @@ +import { + paintPayloadSketch, + computePreviewCssSize, + computeSplitPreviewLayout, + resolvePayloadForDryRun, +} from './preview_sketch.js'; +import { formatPayloadYamlList } from './yaml_util.js'; +import { imageEntityForDevice, dimensionsFromPreviewImage } from './dimensions.js'; +import { readServiceData } from './export.js'; +import { estimateItemBounds, getResizeHandles } from './sketch_hit.js'; + +/** @param {string} entityId */ +export function imageServePath(entityId) { + return `/api/image/serve/${entityId}`; +} + +/** + * Resolve entity_picture from latest hass snapshot (panel setHass updates this). + * @param {() => any} getHass + * @param {string} entityId + */ +export function entityPicturePath(getHass, entityId) { + const pic = getHass()?.states?.[entityId]?.attributes?.entity_picture; + if (pic && String(pic).trim()) return String(pic); + return imageServePath(entityId); +} + +/** + * Wait for entity_picture URL to change in hass state (fallback only). + * @param {() => any} getHass + */ +export async function waitForPictureChange(getHass, entityId, prevPic, timeoutMs = 1500) { + const sleep = (/** @type {number} */ ms) => + new Promise((/** @type {(v: void) => void} */ r) => setTimeout(r, ms)); + const t0 = Date.now(); + let delay = 0; + while (Date.now() - t0 < timeoutMs) { + const pic = getHass()?.states?.[entityId]?.attributes?.entity_picture; + if (pic && pic !== prevPic) return pic; + await sleep(delay); + delay = Math.min(48, delay + 16); + } + const pic = getHass()?.states?.[entityId]?.attributes?.entity_picture; + return pic && String(pic).trim() ? pic : ''; +} + +/** @param {unknown} err */ +export function errMsg(err) { + if (err && typeof err === 'object') { + const message = Reflect.get(err, 'message'); + const code = Reflect.get(err, 'code'); + const body = Reflect.get(err, 'body'); + let bodyStr = ''; + if (body && typeof body === 'object' && body.message) { + bodyStr = String(body.message); + } + return ( + [typeof message === 'string' ? message : '', bodyStr].filter(Boolean).join(' — ') || + (typeof code === 'string' ? code : 'Error') + ); + } + return String(err); +} + +/** @param {Record} deps */ +export function createPreviewRuntime(deps) { + const { + shadow, + previewFrame, + previewImgEl, + getTagPx, + getHass, + effectiveDeviceId, + isVirtualDeviceSelected, + silentParsePayload, + lastParseError, + showToast, + $, + state, + } = deps; + + /** @returns {'sketch' | 'overlay' | 'ha' | 'split'} */ + function getPreviewMode() { + const el = shadow.querySelector('input[name="od-pvm"]:checked'); + const v = el ? /** @type {HTMLInputElement} */ (el).value : 'sketch'; + if (v === 'overlay' || v === 'ha' || v === 'split') return v; + return 'sketch'; + } + + function previewImageHasSrc() { + const s = previewImgEl.getAttribute('src'); + return !!(s && s.trim()); + } + + function refreshHaStaleUi() { + const hint = + /** @type {HTMLElement | null} */ (shadow.querySelector('#od-ha-stale-hint')); + const mode = getPreviewMode(); + const hasBmp = previewImageHasSrc(); + const ref = + state.haBmpReflectsYamlBump !== null && + typeof state.haBmpReflectsYamlBump === 'number' && + !Number.isNaN(/** @type {number} */ (state.haBmpReflectsYamlBump)); + const stale = + mode === 'overlay' && + hasBmp && + ref && + state.yamlPreviewBump !== /** @type {number} */ (state.haBmpReflectsYamlBump); + previewFrame.classList.toggle('od-overlay-ha-stale', stale); + if (hint) hint.hidden = !stale; + } + + function refreshPreviewPlaceholderVisibility() { + const ph = $('#od-preview-placeholder'); + const mode = getPreviewMode(); + ph.hidden = + mode === 'sketch' || + ((mode === 'overlay' || mode === 'ha' || mode === 'split') && previewImageHasSrc()); + } + + function syncPreviewFrameClass() { + previewFrame.classList.remove( + 'pvm-sketch', + 'pvm-overlay', + 'pvm-ha', + 'pvm-split', + 'od-overlay-ha-stale' + ); + previewFrame.classList.add(`pvm-${getPreviewMode()}`); + refreshPreviewPlaceholderVisibility(); + refreshHaStaleUi(); + } + + function layoutPreviewDims() { + const fromImg = dimensionsFromPreviewImage(previewImgEl); + if (fromImg) return fromImg; + return getTagPx(); + } + + function applyCombinedPreviewLayout() { + const { tagW, tagH } = layoutPreviewDims(); + previewFrame.classList.remove('od-split-cols', 'od-split-rows'); + if (getPreviewMode() === 'split') { + const split = computeSplitPreviewLayout(previewFrame, tagW, tagH); + previewFrame.classList.add( + split.direction === 'columns' ? 'od-split-cols' : 'od-split-rows' + ); + previewFrame.style.setProperty('--od-preview-w', `${split.cssW}px`); + previewFrame.style.setProperty('--od-preview-h', `${split.cssH}px`); + return { tagW, tagH, cssW: split.cssW, cssH: split.cssH }; + } + const { cssW, cssH } = computePreviewCssSize(previewFrame, tagW, tagH); + previewFrame.style.setProperty('--od-preview-w', `${cssW}px`); + previewFrame.style.setProperty('--od-preview-h', `${cssH}px`); + return { tagW, tagH, cssW, cssH }; + } + + function drawSketchMessage(canvas, msg) { + const { cssW, cssH } = applyCombinedPreviewLayout(); + const dpr = Math.min(2, window.devicePixelRatio || 1); + canvas.style.width = `${cssW}px`; + canvas.style.height = `${cssH}px`; + canvas.width = Math.round(cssW * dpr); + canvas.height = Math.round(cssH * dpr); + const ctx = canvas.getContext('2d'); + if (!ctx) return; + delete canvas.__odPxToTag; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, cssW, cssH); + ctx.fillStyle = '#666'; + ctx.font = '13px system-ui,sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(msg, cssW / 2, cssH / 2); + } + + function scheduleRedrawSketch() { + if (state.sketchDebounceTimer) clearTimeout(state.sketchDebounceTimer); + state.sketchDebounceTimer = setTimeout(redrawSketch, 90); + } + + function redrawSketch() { + state.sketchDebounceTimer = null; + const canvas = /** @type {HTMLCanvasElement} */ (shadow.querySelector('#od-sketch')); + const parsed = silentParsePayload(); + if (!parsed) { + state.selectedItemIdx = -1; + drawSketchMessage(canvas, lastParseError() || 'Invalid YAML'); + return; + } + if (state.selectedItemIdx >= parsed.length) state.selectedItemIdx = -1; + const { tagW, tagH, cssW, cssH } = applyCombinedPreviewLayout(); + const bg = /** @type {HTMLSelectElement} */ ($('#od-bg')).value; + const rot = Number(/** @type {HTMLSelectElement} */ ($('#od-rot')).value); + const mode = getPreviewMode(); + const hass = getHass(); + let selectedBounds = null; + let selectedHandles = null; + let activeHandleId = null; + if ( + state.selectedItemIdx >= 0 && + state.selectedItemIdx < parsed.length + ) { + const selected = parsed[state.selectedItemIdx]; + selectedBounds = estimateItemBounds(selected, hass, tagW, tagH); + selectedHandles = getResizeHandles(selected, hass, tagW, tagH); + activeHandleId = state.sketchEdit?.handle?.id ?? null; + } + paintPayloadSketch( + canvas, + hass, + parsed, + tagW, + tagH, + bg, + rot, + '#cc2200', + mode === 'overlay', + { cssW, cssH }, + selectedBounds, + selectedHandles, + activeHandleId + ); + } + + function yamlEditShowSketch() { + bumpYamlPreviewGenOnly(); + scheduleRedrawSketch(); + } + + function switchToHaPreview() { + if (getPreviewMode() === 'split') return; + const haInp = shadow.querySelector('input[name="od-pvm"][value="ha"]'); + if (haInp) /** @type {HTMLInputElement} */ (haInp).checked = true; + syncPreviewFrameClass(); + } + + function bumpYamlPreviewGenOnly() { + state.yamlPreviewBump += 1; + state.pendingAutoHaBumpTarget = null; + refreshHaStaleUi(); + } + + function leaveHaForEdit() { + if (getPreviewMode() === 'overlay' || getPreviewMode() === 'sketch') { + redrawSketch(); + return; + } + if (getPreviewMode() !== 'ha') return; + const sel = previewImageHasSrc() ? 'overlay' : 'sketch'; + const inp = shadow.querySelector(`input[name="od-pvm"][value="${sel}"]`); + if (inp) /** @type {HTMLInputElement} */ (inp).checked = true; + syncPreviewFrameClass(); + redrawSketch(); + } + + function applyPreviewPicture(picPath, yamlBumpWhenRequestStarted) { + if (!picPath) return; + state.pendingAutoHaBumpTarget = state.yamlPreviewBump; + const devId = effectiveDeviceId(); + if (devId) previewImgEl.dataset.odDeviceId = devId; + previewImgEl.dataset.odRequestBump = String( + yamlBumpWhenRequestStarted ?? state.yamlPreviewBump + ); + const abs = /^https?:/i.test(picPath) + ? picPath + : `${window.location.origin}${picPath.startsWith('/') ? picPath : `/${picPath}`}`; + const sep = abs.includes('?') ? '&' : '?'; + previewImgEl.src = `${abs}${sep}t=${Date.now()}`; + previewImgEl.hidden = false; + refreshPreviewPlaceholderVisibility(); + } + + function clearHaDryRunPreview() { + state.pendingAutoHaBumpTarget = null; + state.haBmpReflectsYamlBump = null; + delete previewImgEl.dataset.odRequestBump; + delete previewImgEl.dataset.odDeviceId; + previewImgEl.removeAttribute('src'); + previewImgEl.hidden = true; + refreshPreviewPlaceholderVisibility(); + refreshHaStaleUi(); + } + + async function invokeDrawcustom(dryRun, payloadArr, quiet) { + const hass = getHass(); + const devId = effectiveDeviceId(); + if (!devId) throw new Error('No device'); + if (isVirtualDeviceSelected()) { + throw new Error( + 'Virtual device has no Home Assistant target. Use a real OpenDisplay device for HA preview/send.' + ); + } + const imgBefore = imageEntityForDevice(hass, devId); + const previewTicket = + dryRun && imgBefore ? ++state.haDryApplyTicket : /** @type {-1 | number} */ (-1); + const data = { + ...readServiceData($, dryRun), + payload: dryRun ? resolvePayloadForDryRun(hass, payloadArr) : payloadArr, + }; + const picBefore = + imgBefore && hass.states[imgBefore]?.attributes?.entity_picture; + const yamlBumpWhenRequestStarted = state.yamlPreviewBump; + await hass.callService('opendisplay', 'drawcustom', data, { device_id: devId }); + if (dryRun) { + state.lastHaPreviewPayloadYaml = formatPayloadYamlList( + resolvePayloadForDryRun(hass, payloadArr) + ); + } + if (dryRun && imgBefore && previewTicket >= 0) { + // Render is done when callService resolves; reload immediately (cache-busted src). + let picPath = entityPicturePath(getHass, imgBefore); + if (previewTicket === state.haDryApplyTicket && picPath) { + applyPreviewPicture(picPath, yamlBumpWhenRequestStarted); + } + const picAfter = await waitForPictureChange(getHass, imgBefore, picBefore); + if ( + previewTicket === state.haDryApplyTicket && + picAfter && + picAfter !== picPath + ) { + applyPreviewPicture(picAfter, yamlBumpWhenRequestStarted); + } + } + if (!quiet) { + if (!dryRun) { + showToast('Queued to OpenDisplay.', false); + } else if (!imgBefore) { + showToast('Dry-run OK (no image entity for preview)', false); + } else { + showToast('HA preview refreshed.', false); + } + } + } + + async function quietAutoHaPreview() { + if (isVirtualDeviceSelected()) return; + if (!effectiveDeviceId()) return; + const parsed = silentParsePayload(); + if (!parsed) return; + state.haGeneration += 1; + const myGen = state.haGeneration; + try { + await invokeDrawcustom(true, parsed, true); + } catch { + /* dry-run failures are surfaced only for manual Preview */ + } + if (myGen === state.haGeneration) { + $('#od-ha-status').textContent = `Last HA render ${new Date().toLocaleTimeString()}`; + } + } + + function scheduleDebouncedHaPreview() { + if (state.haPreviewDebounceTimer) clearTimeout(state.haPreviewDebounceTimer); + state.haPreviewDebounceTimer = setTimeout(() => { + state.haPreviewDebounceTimer = null; + void quietAutoHaPreview().catch(() => {}); + }, 180); + } + + async function interactiveDrawcustom(dryRun) { + if (!effectiveDeviceId()) { + showToast('Select an OpenDisplay device.', true); + return; + } + if (!deps.validatePayload() || !deps.lastValidPayload()) { + showToast(deps.lastParseError() || 'Fix YAML', true); + return; + } + const payloadArr = /** @type {unknown[]} */ (deps.lastValidPayload()); + try { + await invokeDrawcustom(dryRun, payloadArr, false); + $('#od-ha-status').textContent = `Last HA render ${new Date().toLocaleTimeString()}`; + } catch (e) { + showToast(errMsg(e), true); + } + } + + function onPreviewImageLoad() { + applyCombinedPreviewLayout(); + const raw = previewImgEl.dataset.odRequestBump; + if (raw !== undefined && raw !== '') { + const n = parseInt(String(raw).trim(), 10); + state.haBmpReflectsYamlBump = Number.isFinite(n) ? n : null; + } else { + state.haBmpReflectsYamlBump = null; + } + refreshHaStaleUi(); + if ( + state.pendingAutoHaBumpTarget !== null && + state.pendingAutoHaBumpTarget === state.yamlPreviewBump + ) { + switchToHaPreview(); + state.pendingAutoHaBumpTarget = null; + } + if (getPreviewMode() !== 'ha') scheduleRedrawSketch(); + } + + return { + getPreviewMode, + syncPreviewFrameClass, + refreshPreviewPlaceholderVisibility, + refreshHaStaleUi, + applyCombinedPreviewLayout, + drawSketchMessage, + scheduleRedrawSketch, + redrawSketch, + yamlEditShowSketch, + bumpYamlPreviewGenOnly, + leaveHaForEdit, + applyPreviewPicture, + clearHaDryRunPreview, + scheduleDebouncedHaPreview, + interactiveDrawcustom, + onPreviewImageLoad, + }; +} diff --git a/custom_components/opendisplay/designer/frontend/app/preview_sketch.js b/custom_components/opendisplay/designer/frontend/app/preview_sketch.js new file mode 100644 index 0000000..ccc0770 --- /dev/null +++ b/custom_components/opendisplay/designer/frontend/app/preview_sketch.js @@ -0,0 +1,846 @@ +/** + * Browser sketch preview aligned with odl-renderer layout rules where possible. + * Fonts/icons still differ; dry-run sends these resolved values to drawcustom. + */ + +/** @param {unknown} value @param {number} totalDim */ +export function parseDimension(value, totalDim) { + if (typeof value === 'number' && Number.isFinite(value)) return Math.round(value); + const s = String(value ?? '').trim(); + if (!s) return 0; + if (s.endsWith('%')) { + const p = parseFloat(s.slice(0, -1)); + return Number.isFinite(p) ? Math.round((p / 100) * totalDim) : 0; + } + const n = parseFloat(s); + return Number.isFinite(n) ? Math.round(n) : 0; +} + +const COLORED_TEXT_RE = new RegExp( + String.raw`\[(black|b|white|w|red|r|yellow|y|blue|bl|green|gr|g|accent|a|half_black|half_white|half_red|half_yellow|half_accent|gray|grey|hb|hw|hr|hy|ha|#[0-9A-Fa-f]{3}|#[0-9A-Fa-f]{6})\]([\s\S]*?)\[/\1\]`, + 'gi' +); + +/** @param {string} text @returns {Array<{ text: string; color: string }>} */ +function parseColoredText(text) { + const segments = []; + let pos = 0; + COLORED_TEXT_RE.lastIndex = 0; + let m = COLORED_TEXT_RE.exec(text); + while (m) { + if (m.index > pos) { + segments.push({ text: text.slice(pos, m.index), color: 'black' }); + } + segments.push({ text: m[2], color: m[1] }); + pos = m.index + m[0].length; + m = COLORED_TEXT_RE.exec(text); + } + if (pos < text.length) { + segments.push({ text: text.slice(pos), color: 'black' }); + } + return segments.length ? segments : [{ text, color: 'black' }]; +} + +/** + * @param {CanvasRenderingContext2D} ctx + * @param {number} x + * @param {number} y + * @param {Array<{ text: string; color: string }>} segments + * @param {number} fontSize + * @param {string} accentColor + */ +function drawColoredSegments(ctx, x, y, segments, fontSize, accentColor) { + let cx = x; + for (const seg of segments) { + ctx.fillStyle = cssColor(seg.color, accentColor); + ctx.fillText(seg.text, cx, y); + cx += ctx.measureText(seg.text).width; + } +} + +/** @param {string} c */ +function cssColor(c, accentFallback) { + if (!c || typeof c !== 'string') return '#000'; + const s = c.trim().toLowerCase(); + const map = { + black: '#000', + white: '#fff', + red: '#c00', + yellow: '#c9a000', + accent: accentFallback, + a: accentFallback, + half_black: '#888', + gray: '#888', + grey: '#888', + half_red: '#e88', + half_yellow: '#dd8', + half_accent: accentFallback, + ha: accentFallback, + b: '#000', + w: '#fff', + r: '#c00', + y: '#c9a00', + hb: '#888', + hw: '#ccc', + hr: '#e88', + hy: '#dd8', + }; + if (map[s]) return map[s]; + if (s.startsWith('#') && (s.length === 4 || s.length === 7)) return s; + return s; +} + +/** + * Pillow draw.text(..., anchor='lt'): (x, y) is the top-left of the ink bbox. + * Canvas fillText alphabetic baseline: y_baseline ≈ pilTopY + ascent. + * @param {CanvasRenderingContext2D} ctx + * @param {number} pilTopY + * @param {number} fontSizePx + * @param {string} ascentSample single char used for ascent measure + */ +function baselineYFromPilTop(ctx, pilTopY, fontSizePx, ascentSample) { + ctx.textBaseline = 'alphabetic'; + const ch = ascentSample ? String(ascentSample).replace(/\s/g, '') || 'x' : 'x'; + const m = ctx.measureText(ch.slice(0, 1)); + const asc = + Number.isFinite(m.actualBoundingBoxAscent) && + m.actualBoundingBoxAscent > 0 + ? m.actualBoundingBoxAscent + : Math.max(fontSizePx * 0.72, 8); + return pilTopY + asc; +} + +/** + * @param {any} hass + * @param {string} entityId + */ +function entityState(hass, entityId) { + const st = hass?.states?.[entityId]; + if (!st || st.state == null) return ''; + const s = String(st.state); + if (s === 'unknown' || s === 'unavailable') return ''; + return s; +} + +/** + * @param {any} hass + * @param {string} entityId + * @param {string} attr + */ +function entityAttr(hass, entityId, attr) { + const attrs = hass?.states?.[entityId]?.attributes; + if (!attrs || !(attr in attrs)) return ''; + const v = attrs[attr]; + if (v == null) return ''; + if (typeof v === 'object') return JSON.stringify(v); + return String(v); +} + +/** @param {string} value @param {string} [filterExpr] */ +function applyTemplateFilter(value, filterExpr) { + const f = String(filterExpr || ''); + if (/\|\s*int\b/i.test(f)) { + const m = f.match(/int\s*\(\s*(-?\d+)\s*\)/i); + const fallback = m ? parseInt(m[1], 10) : 0; + const n = parseInt(String(value), 10); + return Number.isFinite(n) ? String(n) : String(fallback); + } + if (/\|\s*float\s*\(/i.test(f)) { + const m = f.match(/float\s*\(\s*(-?[\d.]+)\s*\)/i); + const fallback = m ? parseFloat(m[1]) : 0; + const n = parseFloat(String(value)); + return Number.isFinite(n) ? String(n) : String(fallback); + } + if (/\|\s*string\b/i.test(f)) { + return String(value ?? ''); + } + return value; +} + +/** + * @param {string} anchor + * @param {number} blockW + * @param {number} blockH + * @param {number} fontSize + */ +function textAnchorOffset(anchor, blockW, blockH) { + const a = String(anchor || 'lt').toLowerCase(); + const h = a[0] || 'l'; + const v = a[1] || 't'; + let dx = 0; + let dy = 0; + if (h === 'm') dx = -blockW / 2; + else if (h === 'r') dx = -blockW; + if (v === 'm') dy = -blockH / 2; + else if (v === 'b' || v === 'd') dy = -blockH; + return { dx, dy }; +} + +/** Fields in drawcustom elements that may contain HA Jinja templates. */ +const TEMPLATE_FIELD_NAMES = new Set([ + 'value', + 'color', + 'fill', + 'outline', + 'stroke_fill', + 'legend_color', + 'background', + 'url', + 'progress', + 'data', +]); + +/** + * Clone payload and replace `{{ ... }}` with sketch-preview values. + * HA dry-run only — real sends/automation export keep raw templates. + * @param {any} hass + * @param {unknown[]} payload + */ +export function resolvePayloadForDryRun(hass, payload) { + if (!Array.isArray(payload)) return []; + return payload.map((raw) => { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return raw; + const item = { .../** @type {Record} */ (raw) }; + for (const [key, val] of Object.entries(item)) { + if (typeof val === 'string' && TEMPLATE_FIELD_NAMES.has(key) && /\{\{/.test(val)) { + item[key] = resolveTemplates(hass, val); + } + } + return item; + }); +} + +/** + * @param {any} hass + * @param {string} template + */ +export function resolveTemplates(hass, template) { + if (typeof template !== 'string') return String(template ?? ''); + let out = template; + + out = out.replace( + /\{%\s*if\s+is_state\s*\(\s*(['"])([a-zA-Z0-9_.]+)\1\s*,\s*(['"])([^'"]*)\3\s*\)\s*%\}([\s\S]*?)\{%\s*else\s*%\}([\s\S]*?)\{%\s*endif\s*%\}/gi, + (_, _q1, eid, _q2, want, whenTrue, whenFalse) => + entityState(hass, eid) === String(want) ? whenTrue.trim() : whenFalse.trim() + ); + + out = out.replace( + /\{%\s*if\s+is_state\s*\(\s*(['"])([a-zA-Z0-9_.]+)\1\s*,\s*(['"])([^'"]*)\3\s*\)\s*%\}([\s\S]*?)\{%\s*endif\s*%\}/gi, + (_, _q1, eid, _q2, want, body) => + entityState(hass, eid) === String(want) ? body.trim() : '' + ); + + out = out.replace( + /\{\{\s*state_attr\s*\(\s*(['"])([a-zA-Z0-9_.]+)\1\s*,\s*(['"])([a-z0-9_ -]+)\3\s*\)\s*(\|[^}]*)?\s*\}\}/gi, + (_, _q1, eid, _q2, attr, filter) => + applyTemplateFilter(entityAttr(hass, eid, attr), filter) + ); + + out = out.replace( + /\{\{\s*states\s*\(\s*(['"])([a-zA-Z0-9_.]+)\1\s*\)\s*(\|[^}]*)?\s*\}\}/gi, + (_, _q, eid, filter) => applyTemplateFilter(entityState(hass, eid), filter) + ); + + out = out.replace( + /\{\{\s*is_state\s*\(\s*(['"])([a-zA-Z0-9_.]+)\1\s*,\s*(['"])([^'"]*)\3\s*\)\s*(\|[^}]*)?\s*\}\}/gi, + (_, _q1, eid, _q2, want, filter) => { + const actual = entityState(hass, eid); + const match = actual === String(want); + return applyTemplateFilter(match ? 'true' : 'false', filter); + } + ); + + out = out.replace( + /\{\{\s*(['"])([^'"]+)\1\s+if\s+is_state\s*\(\s*(['"])([a-zA-Z0-9_.]+)\3\s*,\s*(['"])([^'"]*)\5\s*\)\s+else\s+(['"])([^'"]+)\7\s*\}\}/gi, + (_, _q1, whenTrue, _q2, eid, _q3, want, _q4, whenFalse) => + entityState(hass, eid) === String(want) ? whenTrue : whenFalse + ); + + return out; +} + +/** + * @param {any} hass + * @param {unknown} template + * @param {number} [fallback] + */ +export function resolveTemplateNumber(hass, template, fallback = 0) { + if (typeof template === 'number' && Number.isFinite(template)) return template; + const raw = String(template ?? '').trim(); + if (!raw) return fallback; + if (!/\{\{/.test(raw)) { + const direct = Number(raw); + return Number.isFinite(direct) ? direct : fallback; + } + const resolved = resolveTemplates(hass, raw); + const n = parseFloat(resolved); + return Number.isFinite(n) ? n : fallback; +} + +/** + * Fit logical bitmap (rw×rh px) inside the preview frame; shared by sketch canvas + HA image overlay. + * @param {HTMLElement | null} container + * @param {number} rw + * @param {number} rh + */ +export function computePreviewCssSize(container, rw, rh) { + const w = Math.max(16, Math.round(rw)) || 296; + const h = Math.max(16, Math.round(rh)) || 128; + const maxCssW = + container && container.clientWidth > 0 ? container.clientWidth - 4 : 480; + const maxCssH = + container && container.clientHeight > 0 ? container.clientHeight - 4 : 560; + const scale = Math.min(maxCssW / w, maxCssH / h) || 1; + return { cssW: Math.round(w * scale), cssH: Math.round(h * scale) }; +} + +/** + * Split preview: pick side-by-side vs stacked from frame size and tag aspect ratio. + * @param {HTMLElement | null} container + * @param {number} rw + * @param {number} rh + * @returns {{ direction: 'columns' | 'rows'; cssW: number; cssH: number; scale: number }} + */ +export function computeSplitPreviewLayout(container, rw, rh) { + const w = Math.max(16, Math.round(rw)) || 296; + const h = Math.max(16, Math.round(rh)) || 128; + const pad = 10; + const labelH = 20; + const gap = 8; + const frameW = + container && container.clientWidth > 0 ? container.clientWidth - pad : 520; + const frameH = + container && container.clientHeight > 0 ? container.clientHeight - pad : 360; + + const paneWCols = Math.max(40, (frameW - gap) / 2); + const paneHCols = Math.max(40, frameH - labelH); + const scaleCols = Math.min(paneWCols / w, paneHCols / h); + + const paneWRows = Math.max(40, frameW); + const paneHRows = Math.max(40, (frameH - labelH * 2 - gap) / 2); + const scaleRows = Math.min(paneWRows / w, paneHRows / h); + + const useColumns = scaleCols >= scaleRows * 0.92; + const scale = Math.max(0.08, useColumns ? scaleCols : scaleRows); + return { + direction: useColumns ? 'columns' : 'rows', + cssW: Math.max(8, Math.round(w * scale)), + cssH: Math.max(8, Math.round(h * scale)), + scale, + }; +} + +/** + * @param {HTMLCanvasElement} canvas + * @param {any} hass + * @param {unknown[]} payload + * @param {number} tagW + * @param {number} tagH + * @param {string} background + * @param {number} rotateDeg + * @param {string} accentColor + * @param {boolean} [transparentBackdrop] when true (e.g. overlay on HA bitmap), skip solid fill + * @param {{ cssW: number; cssH: number } | null} [fixedCss] when set (e.g. from panel), keeps sketch/img overlay pixel-aligned + * @param {{ x:number; y:number; x2:number; y2:number } | null} [selectedBounds] + * @param {Array<{ id:string; x:number; y:number }> | null} [selectedHandles] + * @param {string | null} [activeHandleId] + */ +export function paintPayloadSketch( + canvas, + hass, + payload, + tagW, + tagH, + background, + rotateDeg, + accentColor, + transparentBackdrop = false, + fixedCss = null, + selectedBounds = null, + selectedHandles = null, + activeHandleId = null +) { + const w = Math.max(16, Math.round(tagW)) || 296; + const h = Math.max(16, Math.round(tagH)) || 128; + const dpr = Math.min(2, window.devicePixelRatio || 1); + const pilW = + rotateDeg === 90 || rotateDeg === 270 ? h : w; + const pilH = + rotateDeg === 90 || rotateDeg === 270 ? w : h; + const rw = w; + const rh = h; + + const resolved = + fixedCss && fixedCss.cssW > 0 && fixedCss.cssH > 0 + ? fixedCss + : computePreviewCssSize(canvas.parentElement, rw, rh); + const { cssW, cssH } = resolved; + canvas.style.width = `${cssW}px`; + canvas.style.height = `${cssH}px`; + canvas.width = Math.round(rw * dpr); + canvas.height = Math.round(rh * dpr); + + const cAny = /** @type {HTMLCanvasElement & { odScratchCanvas?: HTMLCanvasElement }} */ ( + canvas + ); + let scratch = cAny.odScratchCanvas; + if (!scratch) { + scratch = document.createElement('canvas'); + cAny.odScratchCanvas = scratch; + } + + const ctx = canvas.getContext('2d'); + if (!ctx) { + delete canvas.__odPxToTag; + return; + } + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + if (transparentBackdrop) { + ctx.clearRect(0, 0, rw, rh); + } else { + ctx.fillStyle = cssColor(background, accentColor); + ctx.fillRect(0, 0, rw, rh); + } + + scratch.width = Math.round(pilW * dpr); + scratch.height = Math.round(pilH * dpr); + const pctx = scratch.getContext('2d'); + if (!pctx) { + delete canvas.__odPxToTag; + return; + } + pctx.setTransform(dpr, 0, 0, dpr, 0, 0); + pctx.clearRect(0, 0, pilW, pilH); + + for (const raw of payload) { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue; + const item = /** @type {Record} */ (raw); + const type = String(item.type || '').toLowerCase(); + + pctx.save(); + + switch (type) { + case 'text': { + const val = resolveTemplates(hass, /** @type {string} */ (item.value ?? '')); + const x = parseDimension(item.x, pilW); + const y = + item.y == null + ? 0 + : parseDimension(item.y, pilH); + const size = Math.max(8, parseDimension(item.size ?? 20, pilH)); + const spRaw = Number(item.spacing); + const spacing = Number.isFinite(spRaw) && spRaw >= 0 ? spRaw : 5; + const lineHeight = Math.round(size * 1.2); + const lineTopStep = lineHeight + spacing; + const parseColors = Boolean(item.parse_colors); + const defaultColor = cssColor( + resolveTemplates(hass, String(item.color || 'black')), + accentColor + ); + pctx.font = `${size}px ui-monospace, monospace`; + const lines = val.split(/\r?\n/); + const drawLines = lines.map((line) => line.slice(0, 200)); + let blockW = 0; + for (const line of drawLines) { + blockW = Math.max(blockW, pctx.measureText(line || ' ').width); + } + const blockH = + drawLines.length > 0 + ? lineHeight + (drawLines.length - 1) * lineTopStep + : lineHeight; + const anchor = String( + item.anchor || (drawLines.length > 1 ? 'la' : 'lt') + ); + const { dx, dy } = textAnchorOffset(anchor, blockW, blockH); + let pilTopY = y + dy; + const drawX = x + dx; + for (const line of drawLines) { + const by = baselineYFromPilTop( + pctx, + pilTopY, + size, + line || 'x' + ); + if (parseColors) { + drawColoredSegments( + pctx, + drawX, + by, + parseColoredText(line), + size, + accentColor + ); + } else { + pctx.fillStyle = defaultColor; + pctx.fillText(line, drawX, by); + } + pilTopY += lineTopStep; + } + break; + } + case 'line': { + pctx.strokeStyle = cssColor(String(item.color || 'black'), accentColor); + pctx.lineWidth = Number(item.width) || 2; + pctx.beginPath(); + pctx.moveTo(Number(item.x_start) || 0, Number(item.y_start) || 0); + pctx.lineTo(Number(item.x_end) || 0, Number(item.y_end) || 0); + pctx.stroke(); + break; + } + case 'rectangle': { + let x1; + let y1; + let x2; + let y2; + if ( + item.x_start != null && + item.x_end != null && + item.y_start != null && + item.y_end != null + ) { + x1 = Number(item.x_start) || 0; + y1 = Number(item.y_start) || 0; + x2 = Number(item.x_end) || x1 + 48; + y2 = Number(item.y_end) || y1 + 28; + } else { + const x = Number(item.x) || 0; + const y = Number(item.y) || 0; + x1 = x; + y1 = y; + x2 = x + (Number(item.width) || 48); + y2 = y + (Number(item.height) || 28); + } + const lw = Number(item.border_width ?? item.width_outline ?? item.width) || 2; + const fill = item.fill != null ? cssColor(String(item.fill), accentColor) : null; + const outline = cssColor(String(item.outline || 'black'), accentColor); + pctx.lineWidth = lw; + if (fill) { + pctx.fillStyle = fill; + pctx.fillRect( + Math.min(x1, x2), + Math.min(y1, y2), + Math.abs(x2 - x1), + Math.abs(y2 - y1) + ); + } + pctx.strokeStyle = outline; + pctx.strokeRect( + Math.min(x1, x2), + Math.min(y1, y2), + Math.abs(x2 - x1), + Math.abs(y2 - y1) + ); + break; + } + case 'rectangle_pattern': { + const sx0 = Number(item.x_start) || 0; + const sy0 = Number(item.y_start) || 0; + const xsz = Number(item.x_size) || 10; + const ysz = Number(item.y_size) || 10; + const xr = Math.max(1, Math.floor(Number(item.x_repeat) || 1)); + const yr = Math.max(1, Math.floor(Number(item.y_repeat) || 1)); + const xo = Number(item.x_offset) || 0; + const yo = Number(item.y_offset) || 0; + const fillRp = item.fill != null ? cssColor(String(item.fill), accentColor) : null; + const outRp = cssColor(String(item.outline || 'black'), accentColor); + for (let ix = 0; ix < xr; ix += 1) { + for (let iy = 0; iy < yr; iy += 1) { + const px = sx0 + ix * (xsz + xo); + const py = sy0 + iy * (ysz + yo); + if (fillRp) { + pctx.fillStyle = fillRp; + pctx.fillRect(px, py, xsz, ysz); + } + pctx.strokeStyle = outRp; + pctx.lineWidth = Number(item.width) || 1; + pctx.strokeRect(px, py, xsz, ysz); + } + } + break; + } + case 'polygon': { + pctx.strokeStyle = cssColor(String(item.outline || 'black'), accentColor); + pctx.lineWidth = 1; + if (Array.isArray(item.points) && item.points.length > 2) { + pctx.beginPath(); + const pts = /** @type {unknown[]} */ (item.points); + const p0 = /** @type {number[]} */ (pts[0]); + pctx.moveTo(p0?.[0] ?? 0, p0?.[1] ?? 0); + for (let i = 1; i < pts.length; i += 1) { + const p = /** @type {number[]} */ (pts[i]); + pctx.lineTo(p?.[0] ?? 0, p?.[1] ?? 0); + } + pctx.closePath(); + if (item.fill) { + pctx.fillStyle = cssColor(String(item.fill), accentColor); + pctx.fill(); + } + pctx.stroke(); + } + break; + } + case 'circle': { + const cx = Number(item.x) || 0; + const cy = Number(item.y) || 0; + const r = Number(item.radius) || 22; + pctx.beginPath(); + pctx.ellipse(cx, cy, r, r, 0, 0, Math.PI * 2); + if (item.fill) { + pctx.fillStyle = cssColor(String(item.fill), accentColor); + pctx.fill(); + } + pctx.strokeStyle = cssColor(String(item.outline || 'black'), accentColor); + pctx.lineWidth = Number(item.width) || 2; + pctx.stroke(); + break; + } + case 'ellipse': { + const xe1 = Number(item.x_start) || 0; + const ye1 = Number(item.y_start) || 0; + const xe2 = Number(item.x_end) || xe1 + 40; + const ye2 = Number(item.y_end) || ye1 + 28; + const mx = (xe1 + xe2) / 2; + const my = (ye1 + ye2) / 2; + const rx = Math.abs(xe2 - xe1) / 2; + const ry = Math.abs(ye2 - ye1) / 2; + pctx.beginPath(); + pctx.ellipse(mx, my, rx, ry, 0, 0, Math.PI * 2); + if (item.fill) { + pctx.fillStyle = cssColor(String(item.fill), accentColor); + pctx.fill(); + } + pctx.strokeStyle = cssColor(String(item.outline || 'black'), accentColor); + pctx.lineWidth = Number(item.width) || 2; + pctx.stroke(); + break; + } + case 'progress_bar': { + const xs = parseDimension(item.x_start, pilW); + const ys = parseDimension(item.y_start, pilH); + const xe = parseDimension(item.x_end, pilW); + const ye = parseDimension(item.y_end, pilH); + const pct = Math.max( + 0, + Math.min(100, resolveTemplateNumber(hass, item.progress, 0)) + ); + pctx.strokeStyle = '#333'; + pctx.strokeRect(xs, ys, xe - xs, ye - ys); + pctx.fillStyle = cssColor(String(item.fill || 'black'), accentColor); + const pw = ((xe - xs) * pct) / 100; + pctx.fillRect(xs, ys, pw, ye - ys); + break; + } + case 'arc': { + const cx = Number(item.x) || Number(item.cx) || 0; + const cy = Number(item.y) || Number(item.cy) || 0; + const r = Number(item.radius) || 30; + const start = ((Number(item.start_angle) || 0) * Math.PI) / 180; + const end = ((Number(item.end_angle) || 360) * Math.PI) / 180; + pctx.beginPath(); + pctx.arc(cx, cy, r, start, end); + if (item.fill) { + pctx.fillStyle = cssColor(String(item.fill), accentColor); + pctx.fill(); + } + pctx.strokeStyle = cssColor(String(item.outline || 'black'), accentColor); + pctx.stroke(); + break; + } + case 'icon': { + const x = parseDimension(item.x, pilW); + const y = parseDimension(item.y, pilH); + const sz = parseDimension(item.size ?? 24, pilH); + const color = cssColor( + resolveTemplates(hass, String(item.color || 'black')), + accentColor + ); + const name = resolveTemplates(hass, String(item.value || 'mdi:help')); + pctx.fillStyle = color; + pctx.strokeStyle = color; + pctx.strokeRect(x, y, sz * 1.1, sz * 1.1); + pctx.font = `${Math.max(8, sz * 0.35)}px sans-serif`; + pctx.textBaseline = 'top'; + pctx.fillText(name.replace(/^mdi:/, '').slice(0, 8), x, y); + break; + } + case 'icon_sequence': { + const x0 = Number(item.x) || 0; + const y0 = Number(item.y) || 0; + const sz = Number(item.size) || 20; + const sp = Number(item.spacing) || sz / 4; + const icons = /** @type {unknown[]} */ (Array.isArray(item.icons) ? item.icons : []); + const dir = String(item.direction || 'right'); + pctx.strokeStyle = cssColor(String(item.fill || 'black'), accentColor); + pctx.font = `${Math.max(8, sz * 0.3)}px sans-serif`; + pctx.textBaseline = 'top'; + for (let i = 0; i < Math.min(icons.length, 6); i += 1) { + const nm = String(icons[i] || '').replace(/^mdi:/, '').slice(0, 4); + if (dir === 'down' || dir === 'up') { + pctx.strokeRect(x0, y0 + i * (sz + sp), sz, sz); + pctx.fillText(nm, x0 + 2, y0 + i * (sz + sp) + 3); + } else { + pctx.strokeRect(x0 + i * (sz + sp), y0, sz, sz); + pctx.fillText(nm, x0 + i * (sz + sp) + 2, y0 + 3); + } + } + break; + } + case 'multiline': { + const x = parseDimension(item.x, pilW); + const y = parseDimension(item.y, pilH); + const oy = parseDimension(item.offset_y ?? 20, pilH); + const fz = Math.max(8, parseDimension(item.size ?? 18, pilH)); + const color = cssColor( + resolveTemplates(hass, String(item.color || 'black')), + accentColor + ); + const del = String(item.delimiter || '|'); + const val = resolveTemplates(hass, String(item.value ?? '')); + const lines = val.split(del); + const parseColors = Boolean(item.parse_colors); + pctx.font = `${fz}px ui-monospace, monospace`; + let pilTopY = y; + for (const line of lines) { + const slice = line.slice(0, 120); + const by = baselineYFromPilTop(pctx, pilTopY, fz, slice || 'x'); + if (parseColors) { + drawColoredSegments( + pctx, + x, + by, + parseColoredText(slice), + fz, + accentColor + ); + } else { + pctx.fillStyle = color; + pctx.fillText(slice, x, by); + } + pilTopY += oy; + } + break; + } + case 'qrcode': + case 'qr_code': { + const x = Number(item.x) || 0; + const y = Number(item.y) || 0; + const box = Number(item.box_size) || Math.min(80, (Number(item.boxsize) || 2) * 28); + pctx.strokeStyle = '#000'; + pctx.strokeRect(x, y, box, box); + pctx.fillStyle = '#000'; + pctx.font = '10px monospace'; + pctx.fillText('QR', x + 3, y + 12); + break; + } + case 'dlimg': { + const x = Number(item.x) || 0; + const y = Number(item.y) || 0; + const iw = Number(item.xsize) || 48; + const ih = Number(item.ysize) || 36; + pctx.strokeStyle = '#444'; + pctx.setLineDash([4, 3]); + pctx.strokeRect(x, y, iw, ih); + pctx.setLineDash([]); + pctx.fillStyle = '#666'; + pctx.font = '10px sans-serif'; + pctx.fillText('img', x + 4, y + 14); + break; + } + case 'diagram': { + const dx = Number(item.x) || 0; + const dy = Number(item.y) || 0; + const dh = Number(item.height) || 80; + const dw = Number(item.width) || Math.min(pilW - dx - 8, 200); + const m = Number(item.margin) || 18; + const yBase = dy + 6; + pctx.strokeStyle = '#333'; + pctx.lineWidth = 1; + pctx.beginPath(); + pctx.moveTo(dx + m, yBase); + pctx.lineTo(dx + m, yBase + dh - m); + pctx.lineTo(dx + dw, yBase + dh - m); + pctx.stroke(); + pctx.fillStyle = '#666'; + pctx.font = '10px sans-serif'; + pctx.fillText('diagram', dx + m + 4, yBase + 12); + break; + } + case 'plot': { + const xs = Number(item.x_start); + const ys = Number(item.y_start); + const xe = Number(item.x_end); + const ye = Number(item.y_end); + pctx.strokeStyle = '#666'; + pctx.strokeRect(xs, ys, xe - xs, ye - ys); + pctx.fillStyle = '#666'; + pctx.font = '10px sans-serif'; + pctx.fillText('plot', xs + 2, ys + 12); + break; + } + case 'debug_grid': { + pctx.strokeStyle = 'rgba(0,0,0,0.15)'; + const sp = Number(item.spacing) || 20; + for (let gx = 0; gx < pilW; gx += sp) { + pctx.beginPath(); + pctx.moveTo(gx, 0); + pctx.lineTo(gx, pilH); + pctx.stroke(); + } + for (let gy = 0; gy < pilH; gy += sp) { + pctx.beginPath(); + pctx.moveTo(0, gy); + pctx.lineTo(pilW, gy); + pctx.stroke(); + } + break; + } + default: { + const x = Number(item.x) || Number(item.x_start) || 8; + const y = Number(item.y) || Number(item.y_start) || 8; + pctx.fillStyle = 'rgba(128,128,128,0.5)'; + pctx.font = '11px sans-serif'; + pctx.fillText(`[${type || '?'}]`, x, y); + } + } + pctx.restore(); + } + + if (selectedBounds) { + const bx = Math.min(selectedBounds.x, selectedBounds.x2); + const by = Math.min(selectedBounds.y, selectedBounds.y2); + const bw = Math.max(1, Math.abs(selectedBounds.x2 - selectedBounds.x)); + const bh = Math.max(1, Math.abs(selectedBounds.y2 - selectedBounds.y)); + pctx.save(); + pctx.strokeStyle = 'rgba(17, 115, 255, 0.95)'; + pctx.lineWidth = 1.25; + pctx.setLineDash([4, 3]); + pctx.strokeRect(bx, by, bw, bh); + pctx.setLineDash([]); + if (Array.isArray(selectedHandles)) { + for (const h of selectedHandles) { + const active = activeHandleId && activeHandleId === h.id; + pctx.beginPath(); + pctx.arc(h.x, h.y, active ? 4.8 : 4.1, 0, Math.PI * 2); + pctx.fillStyle = active ? '#0b62e0' : '#1173ff'; + pctx.fill(); + pctx.strokeStyle = '#fff'; + pctx.lineWidth = 1.1; + pctx.stroke(); + } + } + pctx.restore(); + } + + ctx.save(); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + if (rotateDeg === 90) { + ctx.transform(0, -1, 1, 0, 0, pilW - 1); + } else if (rotateDeg === 180) { + ctx.transform(-1, 0, 0, -1, w - 1, h - 1); + } else if (rotateDeg === 270) { + ctx.transform(0, 1, -1, 0, pilH - 1, 0); + } + ctx.drawImage(scratch, 0, 0, pilW, pilH); + canvas.__odPxToTag = ctx.getTransform().inverse(); + ctx.restore(); +} diff --git a/custom_components/opendisplay/designer/frontend/app/sketch_edit.js b/custom_components/opendisplay/designer/frontend/app/sketch_edit.js new file mode 100644 index 0000000..5489348 --- /dev/null +++ b/custom_components/opendisplay/designer/frontend/app/sketch_edit.js @@ -0,0 +1,454 @@ +import { resolvePlotDataEntities } from './entity_autocomplete.js'; +import { + canvasClientToTagPoint, + estimateItemBounds, + getResizeHandles, + hitTestHandle, + hitTestPayload, + resizePayloadItem, + translatePayloadItem, +} from './sketch_hit.js'; + +export const MIME_OD_PALETTE = 'application/x-opendisplay-designer-palette'; + +/** @type {Record>} */ +export const PALETTE_DEFAULTS = { + text: { type: 'text', value: 'Hello', x: 8, y: 8, size: 22, color: 'black' }, + multiline: { + type: 'multiline', + x: 8, + y: 12, + value: 'Alpha|Beta|Gamma', + delimiter: '|', + offset_y: 22, + size: 18, + color: 'black', + }, + line: { + type: 'line', + x_start: 8, + y_start: 20, + x_end: 120, + y_end: 80, + color: 'black', + width: 2, + }, + rectangle: { + type: 'rectangle', + x_start: 8, + y_start: 8, + x_end: 104, + y_end: 56, + outline: 'black', + fill: 'white', + width: 2, + }, + rectangle_pattern: { + type: 'rectangle_pattern', + x_start: 8, + y_start: 68, + x_size: 10, + y_size: 10, + x_repeat: 4, + y_repeat: 2, + x_offset: 4, + y_offset: 4, + outline: 'black', + fill: 'white', + }, + polygon: { + type: 'polygon', + points: [ + [24, 16], + [100, 32], + [88, 88], + [20, 64], + ], + outline: 'black', + fill: 'white', + }, + circle: { + type: 'circle', + x: 80, + y: 72, + radius: 28, + outline: 'black', + fill: 'white', + width: 2, + }, + ellipse: { + type: 'ellipse', + x_start: 8, + y_start: 94, + x_end: 180, + y_end: 118, + outline: 'black', + fill: 'white', + width: 2, + }, + arc: { + type: 'arc', + x: 148, + y: 72, + radius: 32, + start_angle: 0, + end_angle: 220, + outline: 'black', + fill: 'white', + width: 2, + }, + icon: { + type: 'icon', + value: 'mdi:home', + x: 16, + y: 24, + size: 32, + color: 'black', + anchor: 'la', + }, + icon_sequence: { + type: 'icon_sequence', + x: 8, + y: 118, + size: 20, + spacing: 6, + direction: 'right', + icons: ['mdi:sun-wireless', 'mdi:cloud', 'mdi:weather-night'], + fill: 'black', + anchor: 'la', + }, + qrcode: { + type: 'qrcode', + x: 8, + y: 8, + data: 'https://home-assistant.io', + border: 1, + boxsize: 2, + }, + dlimg: { + type: 'dlimg', + x: 8, + y: 8, + url: '/local/your-logo.png', + xsize: 72, + ysize: 48, + }, + plot: { + type: 'plot', + x_start: 8, + y_start: 16, + x_end: 280, + y_end: 118, + low: 0, + high: 30, + duration: 86400, + font: 'ppb.ttf', + size: 10, + data: [{ entity: 'sensor.temperature', color: 'black', width: 2 }], + yaxis: { + width: 1, + color: 'black', + grid: true, + grid_color: 'black', + grid_style: 'dotted', + }, + xaxis: { + width: 1, + color: 'black', + grid: true, + grid_color: 'black', + grid_style: 'dotted', + }, + }, + progress_bar: { + type: 'progress_bar', + x_start: 8, + y_start: 100, + x_end: 264, + y_end: 118, + progress: 62, + fill: 'black', + background: 'white', + outline: 'black', + direction: 'right', + width: 2, + show_percentage: true, + }, + diagram: { + type: 'diagram', + x: 12, + y: 14, + height: 100, + width: 268, + margin: 22, + bars: { + values: 'A,8;B,14;C,10', + margin: 8, + legend_size: 9, + font: 'ppb.ttf', + legend_color: 'black', + color: 'black', + }, + }, +}; + +export const ELEMENT_PALETTE_ORDER = /** @type {const} */ ([ + ['text', 'text'], + ['multiline', 'multi'], + ['line', 'line'], + ['rectangle', 'rect'], + ['rectangle_pattern', 'pattern'], + ['polygon', 'poly'], + ['circle', 'circle'], + ['ellipse', 'ellipse'], + ['arc', 'arc'], + ['icon', 'icon'], + ['icon_sequence', 'icons'], + ['qrcode', 'qr'], + ['dlimg', 'image'], + ['plot', 'plot'], + ['progress_bar', 'bar'], + ['diagram', 'chart'], +]); + +export const KNOWN_ELEMENT_TYPES = new Set(Object.keys(PALETTE_DEFAULTS)); + +/** + * @param {HTMLElement} paletteRoot + */ +export function setupPaletteChips(paletteRoot) { + for (const row of ELEMENT_PALETTE_ORDER) { + const kind = row[0]; + const short = row[1]; + if (!(kind in PALETTE_DEFAULTS)) continue; + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'od-palette-chip'; + btn.draggable = true; + btn.dataset.odKind = kind; + btn.textContent = short; + btn.title = kind; + paletteRoot.appendChild(btn); + } + paletteRoot.addEventListener('dragstart', (ev) => { + const chip = /** @type {HTMLElement | null} */ ( + /** @type {HTMLElement} */ (ev.target).closest('.od-palette-chip[data-od-kind]') + ); + if (!chip) return; + const kind = chip.dataset.odKind || ''; + try { + ev.dataTransfer?.setData(MIME_OD_PALETTE, kind); + ev.dataTransfer?.setData('text/plain', kind); + } catch { + /* noop */ + } + const dt = ev.dataTransfer; + if (dt) dt.effectAllowed = 'copy'; + }); +} + +/** + * @param {{ + * sketchCanvas: HTMLCanvasElement; + * previewFrame: HTMLElement; + * previewImgEl: HTMLImageElement; + * getHass: () => any; + * getTagPx: () => { tagW: number; tagH: number }; + * silentParsePayload: () => unknown[] | null; + * applyEditedPayload: (parsed: unknown[]) => void; + * validatePayload: () => boolean; + * updateSelectionUi: () => void; + * showToast: (msg: string, isErr: boolean) => void; + * leaveHaForEdit: () => void; + * scheduleRedrawSketch: () => void; + * state: { + * selectedItemIdx: number; + * sketchEdit: { + * pointerId: number; + * mode: 'move' | 'resize'; + * idx: number; + * lastTx: number; + * lastTy: number; + * handle?: { id: string; kind: string; x: number; y: number; vertexIndex?: number } | null; + * } | null; + * }; + * }} deps + */ +export function attachSketchEditor(deps) { + const { + sketchCanvas, + previewFrame, + previewImgEl, + getHass, + getTagPx, + silentParsePayload, + applyEditedPayload, + validatePayload, + updateSelectionUi, + showToast, + leaveHaForEdit, + scheduleRedrawSketch, + state, + } = deps; + + function clearSelection() { + state.selectedItemIdx = -1; + state.sketchEdit = null; + sketchCanvas.style.cursor = ''; + updateSelectionUi(); + } + + sketchCanvas.addEventListener('pointerdown', (ev) => { + const parsed = silentParsePayload(); + if (!parsed) return; + leaveHaForEdit(); + const pt = canvasClientToTagPoint(sketchCanvas, ev.clientX, ev.clientY); + if (!pt) return; + const { tagW, tagH } = getTagPx(); + const hass = getHass(); + + if (state.selectedItemIdx >= 0 && state.selectedItemIdx < parsed.length) { + const currentHandles = getResizeHandles(parsed[state.selectedItemIdx], hass, tagW, tagH); + const h = hitTestHandle(pt.tx, pt.ty, currentHandles); + if (h) { + sketchCanvas.setPointerCapture(ev.pointerId); + state.sketchEdit = { + pointerId: ev.pointerId, + mode: h.kind === 'meta' ? 'move' : 'resize', + idx: state.selectedItemIdx, + lastTx: pt.tx, + lastTy: pt.ty, + handle: h, + }; + sketchCanvas.style.cursor = 'grabbing'; + ev.preventDefault(); + scheduleRedrawSketch(); + return; + } + } + + const idx = hitTestPayload(pt.tx, pt.ty, parsed, hass, tagW, tagH); + if (idx < 0) { + clearSelection(); + scheduleRedrawSketch(); + return; + } + state.selectedItemIdx = idx; + updateSelectionUi(); + sketchCanvas.setPointerCapture(ev.pointerId); + state.sketchEdit = { + pointerId: ev.pointerId, + mode: 'move', + idx, + lastTx: pt.tx, + lastTy: pt.ty, + handle: null, + }; + sketchCanvas.style.cursor = 'grabbing'; + ev.preventDefault(); + scheduleRedrawSketch(); + }); + + sketchCanvas.addEventListener('pointermove', (ev) => { + if (!state.sketchEdit) { + const parsed = silentParsePayload(); + const pt = canvasClientToTagPoint(sketchCanvas, ev.clientX, ev.clientY); + if (!parsed || !pt || state.selectedItemIdx < 0 || state.selectedItemIdx >= parsed.length) { + sketchCanvas.style.cursor = ''; + return; + } + const { tagW, tagH } = getTagPx(); + const hass = getHass(); + const hs = getResizeHandles(parsed[state.selectedItemIdx], hass, tagW, tagH); + const hh = hitTestHandle(pt.tx, pt.ty, hs); + sketchCanvas.style.cursor = + hh?.cursor || + (hitTestPayload(pt.tx, pt.ty, parsed, hass, tagW, tagH) >= 0 ? 'grab' : ''); + return; + } + const parsed = silentParsePayload(); + if (!parsed || state.sketchEdit.idx >= parsed.length) { + state.sketchEdit = null; + sketchCanvas.style.cursor = ''; + return; + } + const pt = canvasClientToTagPoint(sketchCanvas, ev.clientX, ev.clientY); + if (!pt) return; + const dx = pt.tx - state.sketchEdit.lastTx; + const dy = pt.ty - state.sketchEdit.lastTy; + state.sketchEdit.lastTx = pt.tx; + state.sketchEdit.lastTy = pt.ty; + if (dx === 0 && dy === 0) return; + const el = parsed[state.sketchEdit.idx]; + if (!el || typeof el !== 'object' || Array.isArray(el)) return; + const { tagW, tagH } = getTagPx(); + const hass = getHass(); + if (state.sketchEdit.mode === 'resize' && state.sketchEdit.handle) { + resizePayloadItem(el, state.sketchEdit.handle, dx, dy, { hass, tagW, tagH }); + } else { + translatePayloadItem(el, dx, dy, { tagW, tagH }); + } + applyEditedPayload(parsed); + ev.preventDefault(); + }); + + function stopSketchEdit() { + state.sketchEdit = null; + sketchCanvas.style.cursor = ''; + scheduleRedrawSketch(); + } + + sketchCanvas.addEventListener('pointerup', stopSketchEdit); + sketchCanvas.addEventListener('lostpointercapture', stopSketchEdit); + previewImgEl.addEventListener('pointerdown', () => leaveHaForEdit()); + + previewFrame.addEventListener('dragover', (e) => { + e.preventDefault(); + try { + e.dataTransfer.dropEffect = 'copy'; + } catch { + /* noop */ + } + }); + + previewFrame.addEventListener('drop', (e) => { + e.preventDefault(); + leaveHaForEdit(); + const parsed = silentParsePayload(); + if (!parsed) { + showToast('Fix YAML before adding elements.', true); + return; + } + const pt = canvasClientToTagPoint(sketchCanvas, e.clientX, e.clientY); + if (!pt) return; + const { tagW, tagH } = getTagPx(); + const hass = getHass(); + const kindRaw = + e.dataTransfer?.getData(MIME_OD_PALETTE) || + e.dataTransfer?.getData('text/plain') || + ''; + const kind = String(kindRaw).trim(); + const base = PALETTE_DEFAULTS[kind]; + if (!base) return; + const item = /** @type {Record} */ ( + JSON.parse(JSON.stringify(base)) + ); + if (String(item.type) === 'plot') { + const resolved = resolvePlotDataEntities(hass, item.data); + if (resolved) item.data = resolved; + } + const b = estimateItemBounds(item, hass, tagW, tagH); + if (b) { + const cx = (b.x + b.x2) / 2; + const cy = (b.y + b.y2) / 2; + translatePayloadItem(item, pt.tx - cx, pt.ty - cy, { tagW, tagH }); + } + parsed.push(item); + state.selectedItemIdx = parsed.length - 1; + applyEditedPayload(parsed); + validatePayload(); + updateSelectionUi(); + }); + + return { clearSelection }; +} diff --git a/custom_components/opendisplay/designer/frontend/app/sketch_hit.js b/custom_components/opendisplay/designer/frontend/app/sketch_hit.js new file mode 100644 index 0000000..92505bb --- /dev/null +++ b/custom_components/opendisplay/designer/frontend/app/sketch_hit.js @@ -0,0 +1,728 @@ +import { resolveTemplates, parseDimension } from './preview_sketch.js'; + +const HANDLE_RADIUS = 5.5; +const HANDLE_SIZE = HANDLE_RADIUS * 2; +const MIN_SIZE = 4; +const MIN_RADIUS = 2; + +/** @param {number} n */ +function roundPx(n) { + return Math.round(n); +} + +/** @param {unknown} v */ +function isPercentCoord(v) { + return typeof v === 'string' && /%\s*$/.test(String(v).trim()); +} + +/** + * Shift a pixel or percentage coordinate by delta in tag space. + * @param {unknown} v + * @param {number} delta + * @param {number} totalDim + */ +export function shiftCoord(v, delta, totalDim) { + if (v === undefined || v === null) return undefined; + const wasPercent = isPercentCoord(v); + const px = parseDimension(v, totalDim); + const newPx = roundPx(px + delta); + if (wasPercent && totalDim > 0) { + const pct = Math.max(0, Math.min(100, (newPx / totalDim) * 100)); + const rounded = Math.round(pct * 10) / 10; + return `${rounded}%`; + } + return newPx; +} + +/** @param {unknown} v */ +function num(v) { + const n = Number(v); + return Number.isFinite(n) ? n : 0; +} + +/** @param {number} v */ +function clampMin(v, min) { + return Number.isFinite(v) ? Math.max(min, v) : min; +} + +/** @param {{ x:number,y:number,x2:number,y2:number }} b */ +function bCenter(b) { + return { x: (b.x + b.x2) / 2, y: (b.y + b.y2) / 2 }; +} + +/** + * @typedef {{ + * id: string; + * kind: 'bbox' | 'endpoint' | 'vertex' | 'radius' | 'center' | 'meta'; + * x: number; + * y: number; + * cursor?: string; + * axis?: 'x'|'y'|'xy'; + * vertexIndex?: number; + * }} ResizeHandle + */ + +/** + * Rough axis-aligned bounds in tag space (logical w × h pixels). + * @returns {{ x: number; y: number; x2: number; y2: number } | null} + */ +export function estimateItemBounds(item, hass, tagW, tagH) { + if (!item || typeof item !== 'object' || Array.isArray(item)) return null; + const o = /** @type {Record} */ (item); + const type = String(o.type || '').toLowerCase(); + const fn = (v, d = 0) => { + const n = Number(v); + return Number.isFinite(n) ? n : d; + }; + /** @param {unknown} v @param {number} dim @param {number} [d] */ + const coord = (v, dim, d = 0) => { + if (v === undefined || v === null || v === '') return d; + return parseDimension(v, dim); + }; + const pad = 6; + + switch (type) { + case 'text': { + const x = coord(o.x, tagW); + const y = coord(o.y, tagH); + const size = Math.max(8, fn(o.size, 20)); + const val = resolveTemplates(hass, /** @type {string} */ (o.value ?? '')); + const lines = val.split(/\r?\n/); + const maxLen = Math.max(...lines.map((s) => s.length), 1); + const tw = Math.min(tagW - x, maxLen * size * 0.62 + pad); + const th = Math.min(tagH - y, lines.length * size * 1.2 + pad); + return { x: x - 2, y: y - 2, x2: x + tw, y2: y + th }; + } + case 'line': { + const xs = coord(o.x_start, tagW); + const ys = coord(o.y_start, tagH); + const xe = coord(o.x_end, tagW); + const ye = coord(o.y_end, tagH); + const lw = Math.max(fn(o.width, 2), 4); + const mix = Math.min(xs, xe) - lw; + const miy = Math.min(ys, ye) - lw; + const mx = Math.max(xs, xe) + lw; + const my = Math.max(ys, ye) + lw; + return { x: mix, y: miy, x2: mx, y2: my }; + } + case 'rectangle': { + if (o.x_start != null && o.x_end != null && o.y_start != null && o.y_end != null) { + const x1 = coord(o.x_start, tagW); + const y1 = coord(o.y_start, tagH); + const x2 = coord(o.x_end, tagW); + const y2 = coord(o.y_end, tagH); + return { + x: Math.min(x1, x2), + y: Math.min(y1, y2), + x2: Math.max(x1, x2), + y2: Math.max(y1, y2), + }; + } + const rx = coord(o.x, tagW); + const ry = coord(o.y, tagH); + return { + x: rx, + y: ry, + x2: rx + fn(o.width, 10), + y2: ry + fn(o.height, 10), + }; + } + case 'rectangle_pattern': { + const x0 = coord(o.x_start, tagW); + const y0 = coord(o.y_start, tagH); + const xrep = Math.max(1, Math.floor(Number(o.x_repeat) || 1)); + const yrep = Math.max(1, Math.floor(Number(o.y_repeat) || 1)); + const xs = fn(o.x_size, 8); + const ys = fn(o.y_size, 8); + const xo = fn(o.x_offset, 0); + const yo = fn(o.y_offset, 0); + const bx = x0 + xrep * xs + Math.max(0, xrep - 1) * xo; + const by = y0 + yrep * ys + Math.max(0, yrep - 1) * yo; + return { x: x0 - 2, y: y0 - 2, x2: bx + 2, y2: by + 2 }; + } + case 'polygon': { + const pts = /** @type {unknown[]} */ (o.points); + if (!Array.isArray(pts) || pts.length === 0) return null; + let mix = Infinity; + let miy = Infinity; + let mx = -Infinity; + let my = -Infinity; + for (const pt of pts) { + const p = /** @type {number[]} */ (pt); + const px = Number(p?.[0]); + const py = Number(p?.[1]); + if (!Number.isFinite(px) || !Number.isFinite(py)) continue; + mix = Math.min(mix, px); + miy = Math.min(miy, py); + mx = Math.max(mx, px); + my = Math.max(my, py); + } + if (!Number.isFinite(mix)) return null; + return { x: mix, y: miy, x2: mx, y2: my }; + } + case 'circle': { + const cx = coord(o.x, tagW); + const cy = coord(o.y, tagH); + const r = fn(o.radius, 20); + return { + x: cx - r - 2, + y: cy - r - 2, + x2: cx + r + 2, + y2: cy + r + 2, + }; + } + case 'ellipse': { + const x1 = coord(o.x_start, tagW); + const y1 = coord(o.y_start, tagH); + const x2 = coord(o.x_end, tagW); + const y2 = coord(o.y_end, tagH); + return { + x: Math.min(x1, x2) - 2, + y: Math.min(y1, y2) - 2, + x2: Math.max(x1, x2) + 2, + y2: Math.max(y1, y2) + 2, + }; + } + case 'arc': { + const cx = coord(o.cx ?? o.x, tagW); + const cy = coord(o.cy ?? o.y, tagH); + const r = fn(o.radius, 30); + return { + x: cx - r - 2, + y: cy - r - 2, + x2: cx + r + 2, + y2: cy + r + 2, + }; + } + case 'progress_bar': + case 'plot': { + const xs = coord(o.x_start, tagW); + const ys = coord(o.y_start, tagH); + const xe = coord(o.x_end, tagW); + const ye = coord(o.y_end, tagH); + return { + x: Math.min(xs, xe), + y: Math.min(ys, ye), + x2: Math.max(xs, xe), + y2: Math.max(ys, ye), + }; + } + case 'icon': { + const x = coord(o.x, tagW); + const y = coord(o.y, tagH); + const sz = fn(o.size, 24); + const box = sz * 1.1; + return { + x: x - 2, + y: y - 2, + x2: x + box + 2, + y2: y + box + 2, + }; + } + case 'qrcode': + case 'qr_code': { + const x = coord(o.x, tagW); + const y = coord(o.y, tagH); + const bs = Math.max(24, fn(o.boxsize, 2) * 24); + return { x, y, x2: x + bs, y2: y + bs }; + } + case 'multiline': { + const x = coord(o.x, tagW); + const y = coord(o.y, tagH); + const lines = String(o.value ?? '').split(String(o.delimiter || '|')); + const oy = Math.max(10, fn(o.offset_y, 20)); + return { + x: x - 2, + y: y - 2, + x2: x + Math.min(tagW - x, 160), + y2: y + lines.length * oy + 4, + }; + } + case 'dlimg': { + const x = coord(o.x, tagW); + const y = coord(o.y, tagH); + return { + x, + y, + x2: x + fn(o.xsize, 48), + y2: y + fn(o.ysize, 48), + }; + } + case 'diagram': { + const x = coord(o.x, tagW); + const y = coord(o.y, tagH); + const h = fn(o.height, 80); + const w = fn(o.width, 200); + return { x, y, x2: x + w, y2: y + h }; + } + case 'icon_sequence': { + const x = coord(o.x, tagW); + const y = coord(o.y, tagH); + const sz = fn(o.size, 20); + const sp = fn(o.spacing, sz / 4); + const icons = /** @type {unknown[]} */ (Array.isArray(o.icons) ? o.icons : []); + const n = Math.max(1, icons.length); + const dir = String(o.direction || 'right'); + if (dir === 'down' || dir === 'up') { + return { x: x - 2, y: y - 2, x2: x + sz + 6, y2: y + n * (sz + sp) + 4 }; + } + return { x: x - 2, y: y - 2, x2: x + n * (sz + sp) + 4, y2: y + sz + 6 }; + } + case 'debug_grid': + return { x: 0, y: 0, x2: tagW, y2: tagH }; + default: { + const x = coord(o.x ?? o.x_start, tagW); + const y = coord(o.y ?? o.y_start, tagH); + return { x: x - pad, y: y - pad, x2: x + pad * 10, y2: y + pad * 4 }; + } + } +} + +/** + * Hit test payload item indices from top-most draw order backward. + */ +export function hitTestPayload(pxTag, pyTag, payload, hass, tagW, tagH) { + for (let i = payload.length - 1; i >= 0; i -= 1) { + const item = payload[i]; + const b = estimateItemBounds(item, hass, tagW, tagH); + if (!b) continue; + if ( + pxTag >= b.x && + pyTag >= b.y && + pxTag <= b.x2 && + pyTag <= b.y2 + ) { + return i; + } + } + return -1; +} + +/** + * @param {HTMLCanvasElement} canvas + * @param {number} clientX + * @param {number} clientY + */ +export function canvasClientToTagPoint(canvas, clientX, clientY) { + const inv = /** @type {DOMMatrix | undefined} */ ( + canvas.__odPxToTag + ); + if (!inv) return null; + const rect = canvas.getBoundingClientRect(); + if (!(rect.width > 0) || !(rect.height > 0)) return null; + const mx = + ((clientX - rect.left) / rect.width) * canvas.width; + const my = + ((clientY - rect.top) / rect.height) * canvas.height; + const p = inv.transformPoint(new DOMPoint(mx, my)); + return { tx: p.x, ty: p.y }; +} + +/** + * @param {Record} o + * @returns {{ x:number,y:number,x2:number,y2:number } | null} + */ +function rectEdgesFromItem(o) { + if ( + o.x_start != null && + o.x_end != null && + o.y_start != null && + o.y_end != null + ) { + return { + x: num(o.x_start), + y: num(o.y_start), + x2: num(o.x_end), + y2: num(o.y_end), + }; + } + if (o.x != null || o.y != null || o.width != null || o.height != null) { + const x = num(o.x); + const y = num(o.y); + return { + x, + y, + x2: x + num(o.width || 48), + y2: y + num(o.height || 28), + }; + } + return null; +} + +/** + * @param {{ x:number,y:number,x2:number,y2:number }} b + * @returns {ResizeHandle[]} + */ +function bboxHandles(b) { + const c = bCenter(b); + return [ + { id: 'nw', kind: 'bbox', x: b.x, y: b.y, cursor: 'nwse-resize', axis: 'xy' }, + { id: 'n', kind: 'bbox', x: c.x, y: b.y, cursor: 'ns-resize', axis: 'y' }, + { id: 'ne', kind: 'bbox', x: b.x2, y: b.y, cursor: 'nesw-resize', axis: 'xy' }, + { id: 'e', kind: 'bbox', x: b.x2, y: c.y, cursor: 'ew-resize', axis: 'x' }, + { id: 'se', kind: 'bbox', x: b.x2, y: b.y2, cursor: 'nwse-resize', axis: 'xy' }, + { id: 's', kind: 'bbox', x: c.x, y: b.y2, cursor: 'ns-resize', axis: 'y' }, + { id: 'sw', kind: 'bbox', x: b.x, y: b.y2, cursor: 'nesw-resize', axis: 'xy' }, + { id: 'w', kind: 'bbox', x: b.x, y: c.y, cursor: 'ew-resize', axis: 'x' }, + ]; +} + +/** + * @returns {ResizeHandle[]} + */ +export function getResizeHandles(item, hass, tagW, tagH) { + if (!item || typeof item !== 'object' || Array.isArray(item)) return []; + const o = /** @type {Record} */ (item); + const type = String(o.type || '').toLowerCase(); + const b = estimateItemBounds(item, hass, tagW, tagH); + if (!b) return []; + + if (type === 'line' || type === 'plot' || type === 'progress_bar') { + const xs = num(o.x_start); + const ys = num(o.y_start); + const xe = num(o.x_end); + const ye = num(o.y_end); + const c = { x: (xs + xe) / 2, y: (ys + ye) / 2 }; + return [ + { id: 'p0', kind: 'endpoint', x: xs, y: ys, cursor: 'move' }, + { id: 'p1', kind: 'endpoint', x: xe, y: ye, cursor: 'move' }, + { id: 'body', kind: 'meta', x: c.x, y: c.y, cursor: 'grab' }, + ]; + } + if (type === 'polygon') { + const pts = /** @type {unknown[]} */ (o.points); + if (!Array.isArray(pts)) return bboxHandles(b); + /** @type {ResizeHandle[]} */ + const hs = []; + for (let i = 0; i < pts.length; i += 1) { + const p = /** @type {number[]} */ (pts[i]); + const x = num(p?.[0]); + const y = num(p?.[1]); + hs.push({ + id: `v${i}`, + kind: 'vertex', + x, + y, + cursor: 'move', + vertexIndex: i, + }); + } + return hs; + } + if (type === 'circle' || type === 'arc') { + const cx = type === 'arc' ? num(o.cx ?? o.x) : num(o.x); + const cy = type === 'arc' ? num(o.cy ?? o.y) : num(o.y); + const r = clampMin(num(o.radius || 20), MIN_RADIUS); + return [ + { id: 'center', kind: 'center', x: cx, y: cy, cursor: 'move' }, + { id: 'radius', kind: 'radius', x: cx + r, y: cy, cursor: 'ew-resize' }, + ]; + } + if (type === 'debug_grid') { + const c = bCenter(b); + return [{ id: 'meta', kind: 'meta', x: c.x, y: c.y, cursor: 'default' }]; + } + return bboxHandles(b); +} + +/** + * @param {number} pxTag + * @param {number} pyTag + * @param {ResizeHandle[]} handles + * @returns {ResizeHandle | null} + */ +export function hitTestHandle(pxTag, pyTag, handles) { + for (let i = handles.length - 1; i >= 0; i -= 1) { + const h = handles[i]; + const dx = pxTag - h.x; + const dy = pyTag - h.y; + if (dx * dx + dy * dy <= HANDLE_RADIUS * HANDLE_RADIUS * 1.8) return h; + } + return null; +} + +/** + * @param {Record} o + * @param {{x:number,y:number,x2:number,y2:number}} base + * @param {string} id + * @param {number} dx + * @param {number} dy + */ +function applyBboxResize(o, base, id, dx, dy) { + let x1 = base.x; + let y1 = base.y; + let x2 = base.x2; + let y2 = base.y2; + if (id.includes('w')) x1 += dx; + if (id.includes('e')) x2 += dx; + if (id.includes('n')) y1 += dy; + if (id.includes('s')) y2 += dy; + if (id === 'n' || id === 's') { + x1 = base.x; + x2 = base.x2; + } + if (id === 'e' || id === 'w') { + y1 = base.y; + y2 = base.y2; + } + if (Math.abs(x2 - x1) < MIN_SIZE) { + if (id.includes('w')) x1 = x2 - MIN_SIZE; + else x2 = x1 + MIN_SIZE; + } + if (Math.abs(y2 - y1) < MIN_SIZE) { + if (id.includes('n')) y1 = y2 - MIN_SIZE; + else y2 = y1 + MIN_SIZE; + } + + const type = String(o.type || '').toLowerCase(); + if ( + type === 'rectangle' && + o.x_start != null && + o.x_end != null && + o.y_start != null && + o.y_end != null + ) { + o.x_start = roundPx(x1); + o.y_start = roundPx(y1); + o.x_end = roundPx(x2); + o.y_end = roundPx(y2); + return; + } + if (type === 'ellipse' || type === 'plot' || type === 'progress_bar') { + o.x_start = roundPx(x1); + o.y_start = roundPx(y1); + o.x_end = roundPx(x2); + o.y_end = roundPx(y2); + return; + } + if (type === 'dlimg') { + o.x = roundPx(Math.min(x1, x2)); + o.y = roundPx(Math.min(y1, y2)); + o.xsize = roundPx(clampMin(Math.abs(x2 - x1), MIN_SIZE)); + o.ysize = roundPx(clampMin(Math.abs(y2 - y1), MIN_SIZE)); + return; + } + if (type === 'qrcode' || type === 'qr_code') { + const side = clampMin(Math.max(Math.abs(x2 - x1), Math.abs(y2 - y1)), 8); + o.x = roundPx(Math.min(x1, x2)); + o.y = roundPx(Math.min(y1, y2)); + if (o.box_size != null) o.box_size = roundPx(side); + else o.boxsize = roundPx(Math.max(1, side / 24)); + return; + } + if (type === 'rectangle_pattern') { + const w = clampMin(Math.abs(x2 - x1), MIN_SIZE); + const h = clampMin(Math.abs(y2 - y1), MIN_SIZE); + o.x_start = roundPx(Math.min(x1, x2)); + o.y_start = roundPx(Math.min(y1, y2)); + o.x_size = roundPx(w / Math.max(1, Math.floor(num(o.x_repeat) || 1))); + o.y_size = roundPx(h / Math.max(1, Math.floor(num(o.y_repeat) || 1))); + return; + } + if (type === 'icon' || type === 'icon_sequence') { + const size = clampMin(Math.max(Math.abs(x2 - x1), Math.abs(y2 - y1)), 8); + o.x = roundPx(Math.min(x1, x2)); + o.y = roundPx(Math.min(y1, y2)); + o.size = roundPx(size); + if (type === 'icon_sequence') { + const icons = /** @type {unknown[]} */ (Array.isArray(o.icons) ? o.icons : []); + const n = Math.max(1, icons.length); + const dir = String(o.direction || 'right'); + if (dir === 'down' || dir === 'up') { + o.spacing = roundPx(clampMin((Math.abs(y2 - y1) - n * size) / Math.max(1, n - 1), 0)); + } else { + o.spacing = roundPx(clampMin((Math.abs(x2 - x1) - n * size) / Math.max(1, n - 1), 0)); + } + } + return; + } + if (type === 'text' || type === 'multiline') { + const nextSize = clampMin(Math.max(Math.abs(x2 - x1), Math.abs(y2 - y1)) * 0.18, 8); + o.size = roundPx(nextSize); + if (type === 'multiline' && o.offset_y != null) { + o.offset_y = roundPx(clampMin(num(o.offset_y) + dy, 8)); + } + return; + } + if (type === 'diagram') { + o.x = roundPx(Math.min(x1, x2)); + o.width = roundPx(clampMin(Math.abs(x2 - x1), MIN_SIZE)); + o.height = roundPx(clampMin(Math.abs(y2 - y1), MIN_SIZE)); + return; + } + if (o.x != null || o.y != null || o.width != null || o.height != null) { + o.x = roundPx(Math.min(x1, x2)); + o.y = roundPx(Math.min(y1, y2)); + o.width = roundPx(clampMin(Math.abs(x2 - x1), MIN_SIZE)); + o.height = roundPx(clampMin(Math.abs(y2 - y1), MIN_SIZE)); + } else { + o.x_start = roundPx(x1); + o.y_start = roundPx(y1); + o.x_end = roundPx(x2); + o.y_end = roundPx(y2); + } +} + +/** + * Resize item by handle in tag space (mutates item object). + * @param {unknown} item + * @param {ResizeHandle} handle + * @param {number} dx + * @param {number} dy + * @param {{ hass:any; tagW:number; tagH:number }} opts + */ +export function resizePayloadItem(item, handle, dx, dy, opts) { + if (!item || typeof item !== 'object' || Array.isArray(item)) return; + const o = /** @type {Record} */ (item); + const type = String(o.type || '').toLowerCase(); + if (handle.kind === 'meta') return; + if (type === 'debug_grid') return; + + if (type === 'line' || type === 'plot' || type === 'progress_bar') { + if (handle.id === 'p0') { + o.x_start = roundPx(num(o.x_start) + dx); + o.y_start = roundPx(num(o.y_start) + dy); + } else if (handle.id === 'p1') { + o.x_end = roundPx(num(o.x_end) + dx); + o.y_end = roundPx(num(o.y_end) + dy); + } + return; + } + if (type === 'polygon' && handle.kind === 'vertex') { + const pts = /** @type {unknown[]} */ (o.points); + if (!Array.isArray(pts)) return; + const i = handle.vertexIndex ?? -1; + if (i < 0 || i >= pts.length) return; + const row = /** @type {number[]} */ (Array.isArray(pts[i]) ? [...pts[i]] : [0, 0]); + row[0] = roundPx(num(row[0]) + dx); + row[1] = roundPx(num(row[1]) + dy); + pts[i] = row; + o.points = pts; + return; + } + if (type === 'circle' || type === 'arc') { + if (handle.kind === 'center') { + if (type === 'arc') { + if (o.cx != null || o.x == null) o.cx = roundPx(num(o.cx ?? o.x) + dx); + else o.x = roundPx(num(o.x) + dx); + if (o.cy != null || o.y == null) o.cy = roundPx(num(o.cy ?? o.y) + dy); + else o.y = roundPx(num(o.y) + dy); + } else { + o.x = roundPx(num(o.x) + dx); + o.y = roundPx(num(o.y) + dy); + } + return; + } + if (handle.kind === 'radius') { + const cx = type === 'arc' ? num(o.cx ?? o.x) : num(o.x); + const cy = type === 'arc' ? num(o.cy ?? o.y) : num(o.y); + const baseRadius = clampMin(num(o.radius || 20), MIN_RADIUS); + const nx = cx + baseRadius + dx; + const ny = cy + dy; + o.radius = roundPx(clampMin(Math.hypot(nx - cx, ny - cy), MIN_RADIUS)); + return; + } + } + + const base = estimateItemBounds(item, opts.hass, opts.tagW, opts.tagH); + if (!base) return; + applyBboxResize(o, base, handle.id, dx, dy); +} + +/** + * @returns {{ r:number, d:number }} + */ +export function getHandleVisualSpec() { + return { r: HANDLE_RADIUS, d: HANDLE_SIZE }; +} + +/** + * Translate item geometry by dx, dy in tag space (mutates item object). + * Supports percentage coordinates (e.g. x: "50%") when tagW/tagH are provided. + * @param {{ tagW?: number; tagH?: number }} [opts] + */ +export function translatePayloadItem(item, dx, dy, opts = {}) { + const tagW = Math.max(1, Number(opts.tagW) || 296); + const tagH = Math.max(1, Number(opts.tagH) || 128); + const o = /** @type {Record} */ (item); + const type = String(o.type || '').toLowerCase(); + /** @param {string} k */ + const shift = (k) => { + const v = o[k]; + if (v === undefined || v === null) return; + const isY = k === 'y' || k === 'cy' || k === 'y_start' || k === 'y_end'; + const next = shiftCoord(v, isY ? dy : dx, isY ? tagH : tagW); + if (next !== undefined) o[k] = next; + }; + switch (type) { + case 'text': + case 'multiline': + case 'qr_code': + case 'qrcode': + case 'dlimg': + shift('x'); + shift('y'); + return; + case 'arc': + shift('x'); + shift('y'); + shift('cx'); + shift('cy'); + return; + case 'rectangle': + shift('x_start'); + shift('x_end'); + shift('y_start'); + shift('y_end'); + shift('x'); + shift('y'); + return; + case 'rectangle_pattern': + shift('x_start'); + shift('y_start'); + return; + case 'circle': + shift('x'); + shift('y'); + return; + case 'ellipse': + shift('x_start'); + shift('x_end'); + shift('y_start'); + shift('y_end'); + return; + case 'icon': + case 'icon_sequence': + shift('x'); + shift('y'); + return; + case 'diagram': + shift('x'); + shift('y'); + return; + case 'line': + case 'plot': + case 'progress_bar': + shift('x_start'); + shift('y_start'); + shift('x_end'); + shift('y_end'); + return; + case 'polygon': { + const pts = /** @type {unknown[]} */ (o.points); + if (!Array.isArray(pts)) return; + o.points = pts.map((row) => { + const xy = /** @type {number[]} */ (Array.isArray(row) ? [...row] : []); + xy[0] = roundPx(Number(xy[0]) + dx); + xy[1] = roundPx(Number(xy[1]) + dy); + return xy; + }); + return; + } + default: + shift('x'); + shift('y'); + shift('x_start'); + shift('y_start'); + } +} diff --git a/custom_components/opendisplay/designer/frontend/app/styles.css b/custom_components/opendisplay/designer/frontend/app/styles.css new file mode 100644 index 0000000..1ee23a2 --- /dev/null +++ b/custom_components/opendisplay/designer/frontend/app/styles.css @@ -0,0 +1,1395 @@ +/* ============================================================ + OpenDisplay — Colors, Type & Layout Redesign + Aesthetic: Premium e-paper paper-on-paper look, Geist typography + ============================================================ */ + +:host { + display: block; + height: 100%; + min-height: 0; + background: var(--primary-background-color, #fafafa); + color: var(--primary-text-color, #212121); +} + +/* OpenDisplay brand + Home Assistant theme surfaces (auto light/dark) */ +.od-root { + --od-blue: #00bfff; + --od-blue-ink: #00a6dd; + --od-blue-deep: #0085b3; + --od-blue-soft: #bfefff; + --od-blue-wash: #eaf8ff; + + --od-paper: var(--card-background-color, #fbfaf7); + --od-paper-2: var(--secondary-background-color, var(--primary-background-color, #f2f0ea)); + --od-paper-3: var(--divider-color, #e6e3db); + --od-paper-line: var(--divider-color, #dad6cc); + --od-white: var(--card-background-color, #ffffff); + --od-bezel: var(--primary-text-color, #111418); + + --fg1: var(--primary-text-color, #0b0f12); + --fg2: var(--secondary-text-color, #2a3138); + --fg3: var(--disabled-text-color, #5a6470); + --fg4: var(--disabled-text-color, #8b95a0); + --od-ink-line: var(--divider-color, #c7cdd3); + + --od-ok: #1b8a4e; + --od-warn: #b6770a; + --od-error: var(--error-color, #c0392b); + --od-danger: var(--error-color, #c0392b); + + --font-sans: "Geist", var(--paper-font-common-base_-_font-family, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif); + --font-mono: "Geist Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace; + + --rad-1: 4px; + --rad-2: 8px; + --rad-3: 12px; + --rad-4: 16px; + --rad-5: 22px; + --rad-pill: 999px; + --rad-panel: 14px; + + --bw-hair: 1px; + --bw-frame: 2px; + + --shadow-1: 0 1px 2px rgba(0, 0, 0, 0.08); + --shadow-2: 0 4px 14px -6px rgba(0, 0, 0, 0.14); + --shadow-focus: 0 0 0 3px color-mix(in oklab, var(--od-blue) 40%, transparent); + + box-sizing: border-box; + height: 100%; + max-height: 100%; + min-height: min(100vh, 920px); + padding: 12px; + overflow: hidden; + display: flex; + flex-direction: column; + gap: 10px; + background: var(--primary-background-color, #fafafa); + color: var(--primary-text-color, #212121); + font-family: var(--font-sans); + font-size: 14px; + line-height: 1.45; +} + +.od-root.od-ha-dark { + --od-blue-soft: color-mix(in oklab, var(--od-blue) 22%, var(--card-background-color)); + --od-blue-wash: color-mix(in oklab, var(--od-blue) 10%, var(--card-background-color)); + --shadow-1: 0 1px 2px rgba(0, 0, 0, 0.28); + --shadow-2: 0 4px 14px -6px rgba(0, 0, 0, 0.45); +} + +.od-root *, +.od-root *::before, +.od-root *::after { + box-sizing: border-box; +} + +/* ---------- Cards & Panels ---------- */ +.od-card { + background: var(--od-paper); + border-radius: var(--rad-panel); + padding: 16px; + border: var(--bw-hair) solid var(--od-paper-line); + box-shadow: var(--shadow-1); + transition: box-shadow 120ms ease; +} + +.od-card h2 { + margin: 0 0 12px; + font-size: 1rem; + font-weight: 600; + letter-spacing: -0.01em; + color: var(--fg1); +} + +.od-muted { + color: var(--fg3); + font-size: 13px; + line-height: 1.45; +} + +.od-row-inline { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +@media (max-width: 560px) { + .od-row-inline { + grid-template-columns: 1fr; + } +} + +/* ---------- Toolbar ---------- */ +.od-toolbar.od-card { + flex: 0 0 auto; + display: grid; + grid-template-columns: minmax(260px, 1fr) auto; + gap: 8px 12px; + align-items: end; + padding: 10px 16px; + border-radius: var(--rad-3); +} + +.od-toolbar .od-field { + margin-bottom: 0; +} + +.od-virtual-size { + display: inline-grid; + align-items: center; + grid-template-columns: auto 82px auto 82px; + gap: 8px; + min-width: 216px; +} + +.od-virtual-size label { + font-family: var(--font-mono); + font-size: 11px; + font-weight: 600; + color: var(--fg3); + text-transform: uppercase; + letter-spacing: 0.04em; + line-height: 1; +} + +.od-virtual-size input { + width: 82px; + padding: 6px 10px; + border-radius: var(--rad-2); +} + +.od-virtual-size.is-locked input { + opacity: 0.6; + background: var(--od-paper-2); + cursor: not-allowed; +} + +@media (max-width: 720px) { + .od-toolbar.od-card { + grid-template-columns: 1fr; + } +} + +/* ---------- Main Workspace Layout ---------- */ +.od-main { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: minmax(360px, 0.95fr) minmax(0, 1.7fr); + gap: 10px; + align-items: stretch; +} + +@media (max-width: 1050px) { + .od-main { + grid-template-columns: 1fr; + overflow: auto; + } +} + +.od-col { + display: flex; + flex-direction: column; + min-height: 0; +} + +.od-editor-shell { + flex: 1; + display: flex; + min-height: 0; + border: var(--bw-hair) solid var(--od-paper-line); + border-radius: var(--rad-2); + background: var(--od-white); + overflow: hidden; +} + +.od-editor-gutter { + flex: 0 0 42px; + overflow: hidden; + padding: 10px 6px 10px 8px; + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.5; + color: var(--fg4); + background: var(--od-paper-2); + border-right: var(--bw-hair) solid var(--od-paper-line); + user-select: none; + text-align: right; +} + +.od-gutter-line { + min-height: calc(1.5 * 13px); +} + +.od-gutter-line.od-gutter-err { + color: var(--od-danger); + font-weight: 600; +} + +.od-gutter-line.od-gutter-warn { + color: var(--od-warn); +} + +.od-col.od-col-edit .od-editor-wrap { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + position: relative; + border: none; + background: transparent; +} + +.od-col-visual .od-visual-stack { + flex: 1; + display: flex; + flex-direction: column; + gap: 10px; + min-height: 0; +} + +/* ---------- Visual Panel & Preview ---------- */ +.od-visual-panel { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + border: var(--bw-hair) solid var(--od-paper-line); + border-radius: var(--rad-3); + padding: 12px; + background: var(--od-paper-2); + overflow: hidden; +} + +.od-visual-layout { + flex: 1; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(160px, 200px); + gap: 10px; + min-height: 0; +} + +.od-inspector { + padding: 10px; + min-height: 0; + overflow-y: auto; + align-self: stretch; +} + +.od-inspector-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + margin-bottom: 8px; +} + +.od-inspector-head h3 { + margin: 0; + font-size: 0.85rem; +} + +.od-inspector-type { + font-family: var(--font-mono); + font-size: 11px; + color: var(--fg3); +} + +.od-inspector-field-label { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + flex-wrap: wrap; +} + +.od-inspector-field-key { + font-family: var(--font-mono); + color: var(--fg3); +} + +.od-inspector-field-chips { + display: inline-flex; + flex-wrap: wrap; + gap: 3px; + margin-left: auto; +} + +.od-tpl-chip { + font-family: var(--font-mono); + font-size: 10px; + padding: 2px 8px; + border-radius: 999px; + border: var(--bw-hair) solid var(--od-paper-line); + background: var(--od-white); + color: var(--fg2); + cursor: pointer; +} + +.od-tpl-chip:hover { + border-color: var(--fg3); + background: var(--od-paper); +} + +.od-inspector-field-wrap { + position: relative; +} + +.od-inspector-form { + display: flex; + flex-direction: column; + gap: 8px; +} + +.od-inspector-field { + display: flex; + flex-direction: column; + gap: 3px; + font-size: 11px; +} + +.od-inspector-field span { + color: var(--fg3); + font-family: var(--font-mono); +} + +.od-inspector-field input, +.od-inspector-field textarea, +.od-inspector-field select { + font-family: var(--font-mono); + font-size: 12px; + padding: 6px 8px; + border-radius: var(--rad-1); + border: var(--bw-hair) solid var(--od-paper-line); + background: var(--od-white); +} + +.od-inspector-jump { + width: 100%; + margin-top: 8px; +} + +@media (max-width: 1180px) { + .od-visual-layout { + grid-template-columns: minmax(0, 1fr); + } + .od-inspector { + grid-column: 1 / -1; + max-height: 200px; + } +} + +@media (max-width: 980px) { + .od-visual-layout { + grid-template-columns: 1fr; + } + .od-inspector { + grid-column: auto; + } +} + +.od-workspace-tabs { + display: none; + gap: 4px; + padding: 4px; + background: var(--od-paper); + border: var(--bw-hair) solid var(--od-paper-line); + border-radius: var(--rad-2); +} + +.od-workspace-tabs button { + flex: 1; + padding: 8px 10px !important; + font-size: 12px !important; + background: transparent !important; + color: var(--fg2) !important; + border: var(--bw-hair) solid transparent !important; +} + +.od-workspace-tabs button.active { + background: var(--od-blue-wash) !important; + color: var(--od-blue-ink) !important; + border-color: var(--od-blue-soft) !important; +} + +.od-actions-primary { + margin-left: auto; +} + +.od-export-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 8px; +} + +.od-modal-backdrop { + position: fixed; + inset: 0; + z-index: 2000; + background: rgba(11, 15, 18, 0.45); + display: grid; + place-items: center; + padding: 16px; +} + +.od-modal-backdrop[hidden] { + display: none; +} + +.od-modal { + width: min(420px, 96vw); + max-height: 85vh; + overflow: auto; + background: var(--od-paper); + border: var(--bw-hair) solid var(--od-paper-line); + border-radius: var(--rad-3); + padding: 16px 18px; + box-shadow: var(--shadow-2); +} + +.od-modal-wide { + width: min(720px, 96vw); +} + +.od-modal h3 { + margin: 0 0 12px; +} + +.od-shortcut-row { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 6px 0; + font-size: 13px; + border-bottom: var(--bw-hair) solid var(--od-paper-line); +} + +.od-shortcut-row kbd { + font-family: var(--font-mono); + font-size: 11px; + background: var(--od-paper-2); + padding: 2px 6px; + border-radius: var(--rad-1); +} + +.od-diff-body { + font-family: var(--font-mono); + font-size: 11px; + line-height: 1.45; + max-height: 50vh; + overflow: auto; + border: var(--bw-hair) solid var(--od-paper-line); + border-radius: var(--rad-2); + padding: 8px; + background: var(--od-white); +} + +.od-diff-line.od-diff-add { + color: var(--od-ok); +} + +.od-diff-line.od-diff-del { + color: var(--od-danger); +} + +.od-diff-line.od-diff-chg .del { + display: block; + color: var(--od-danger); +} + +.od-diff-line.od-diff-chg .add { + display: block; + color: var(--od-ok); +} + +.od-split-label { + display: none; + z-index: 4; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--fg3); + font-family: var(--font-mono); + pointer-events: none; + align-self: start; + justify-self: center; + padding-top: 4px; +} + +.od-visual-panel h3 { + margin: 0 0 8px; + font-size: 0.9rem; + font-weight: 600; + letter-spacing: -0.015em; + color: var(--fg1); +} + +.od-preview-head { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px 12px; + margin-bottom: 8px; +} + +.od-preview-head h3 { + margin: 0; +} + +.od-preview-modes { + display: flex; + flex-wrap: wrap; + gap: 8px 14px; + font-size: 12px; + color: var(--fg2); + font-family: var(--font-sans); +} + +.od-preview-modes label { + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 6px; + font-weight: 500; + color: var(--fg2); +} + +.od-preview-modes input[type="radio"] { + accent-color: var(--od-blue); +} + +/* ---------- Color Swatches ---------- */ +.od-color-swatches { + display: inline-flex; + align-items: center; + gap: 6px; + margin-left: auto; +} + +.od-color-chip { + width: 14px; + height: 14px; + border-radius: 50%; + border: 1px solid rgba(11, 15, 18, 0.22); + box-shadow: + 0 0 0 1.5px var(--od-paper), + 0 0 0 2.5px var(--od-paper-line); + cursor: help; + transition: transform 120ms ease, box-shadow 120ms ease; +} + +.od-color-chip:hover { + transform: scale(1.2); + box-shadow: + 0 0 0 1.5px var(--od-paper), + 0 0 0 3px var(--od-blue); +} + +/* ---------- Drag and Drop Palette ---------- */ +.od-palette { + display: flex; + flex-wrap: wrap; + align-items: center; + align-content: flex-start; + gap: 6px; + margin-bottom: 8px; + font-size: 12px; + max-height: min(96px, 16vh); + overflow-y: auto; + padding-bottom: 4px; +} + +.od-palette .od-palette-label { + color: var(--fg3); + font-weight: 600; + text-transform: uppercase; + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.05em; + margin-right: 4px; +} + +.od-palette-chip { + padding: 5px 12px; + border-radius: var(--rad-pill); + border: var(--bw-hair) solid var(--od-paper-line); + background: var(--od-white); + color: var(--fg1); + font-size: 11px; + font-weight: 500; + cursor: grab; + transition: border-color 100ms ease, background-color 100ms ease, color 100ms ease; + font-family: var(--font-sans); +} + +.od-palette-chip:hover { + border-color: var(--od-blue); + background-color: var(--od-blue-wash); + color: var(--od-blue-ink); +} + +.od-palette-chip:active { + cursor: grabbing; +} + +.od-ha-status { + font-size: 11px; + color: var(--fg3); + margin-top: 6px; + font-family: var(--font-mono); +} + +.od-ha-stale-hint { + margin: 8px 0 0; + padding: 8px 12px; + font-size: 12px; + color: var(--od-warn, #b6770a); + background: rgba(182, 119, 10, 0.08); + border: var(--bw-hair) solid rgba(182, 119, 10, 0.25); + border-radius: var(--rad-2); +} + +.od-preview-frame.od-combined-frame.od-overlay-ha-stale { + outline: var(--bw-frame) solid var(--od-warn, #b6770a); + outline-offset: 2px; + border-radius: var(--rad-3); +} + +/* ---------- Fields & Inputs ---------- */ +.od-field { + margin-bottom: 12px; +} + +.od-field.compact-bot { + margin-bottom: 0; +} + +.od-field label { + display: block; + font-size: 12px; + font-weight: 500; + color: var(--fg2); + margin-bottom: 4px; +} + +.od-field select, +.od-field input, +.od-field textarea, +.od-actions select { + box-sizing: border-box; + width: 100%; + padding: 10px 12px; + border: 1.5px solid var(--divider-color, var(--od-ink-line)); + border-radius: var(--rad-2); + background-color: var(--card-background-color, var(--od-white)); + color: var(--primary-text-color, var(--fg1)); + font-family: var(--font-sans); + font-size: 14px; + min-height: 42px; + transition: border-color 80ms linear, box-shadow 80ms linear; + color-scheme: inherit; + -webkit-text-fill-color: var(--primary-text-color, var(--fg1)); +} + +.od-root.od-ha-dark .od-field select, +.od-root.od-ha-dark .od-field input, +.od-root.od-ha-dark .od-field textarea, +.od-root.od-ha-dark .od-actions select { + background-color: var(--card-background-color); + color: var(--primary-text-color); + border-color: var(--divider-color); +} + +.od-field select option, +.od-actions select option { + background-color: var(--card-background-color, var(--od-white)); + color: var(--primary-text-color, var(--fg1)); +} + +.od-root.od-ha-dark .od-field select option, +.od-root.od-ha-dark .od-actions select option { + background-color: var(--card-background-color); + color: var(--primary-text-color); +} + +.od-field select:hover:not(:focus), +.od-field input:hover:not(:focus), +.od-field textarea:hover:not(:focus), +.od-actions select:hover:not(:focus) { + border-color: var(--secondary-text-color, var(--fg2)); +} + +.od-field select:focus, +.od-field input:focus, +.od-field textarea:focus, +.od-actions select:focus { + outline: none; + border-color: var(--od-blue); + box-shadow: var(--shadow-focus); +} + +.od-field input::placeholder, +.od-field textarea::placeholder { + color: var(--fg4); +} + +.od-payload-ta { + flex: 1; + font-family: var(--font-mono) !important; + font-size: 13px; + line-height: 1.5; + min-height: clamp(340px, 52vh, 720px); + resize: vertical; +} + +/* ---------- Diagnostics ---------- */ +.od-parse-status { + margin: 8px 0 4px; + font-size: 12px; + line-height: 1.35; + color: var(--od-danger); +} + +.od-diagnostics { + margin: 6px 0 4px; + padding: 8px 12px; + border-radius: var(--rad-2); + border: var(--bw-hair) solid var(--od-paper-line); + background: var(--od-white); + color: var(--fg2); + font-size: 12px; + line-height: 1.4; +} + +.od-diagnostics strong { + display: block; + margin-bottom: 6px; + color: var(--fg2); +} + +.od-diag-item { + margin: 2px 0; +} + +.od-diag-item.od-diag-err { + color: var(--od-danger); +} + +.od-diag-item.od-diag-warn { + color: var(--od-warn, #b6770a); +} + +/* ---------- Actions & Buttons ---------- */ +.od-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; + flex-shrink: 0; +} + +.od-actions-bar { + flex-wrap: nowrap; + align-items: center; + justify-content: space-between; + gap: 10px; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: thin; +} + +.od-actions-group { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.od-actions-primary { + margin-left: auto; +} + +.od-actions-bar button { + padding: 9px 14px; + font-size: 13px; + white-space: nowrap; +} + +.od-actions select { + min-width: 160px; + max-width: 260px; + width: auto; +} + +.od-root button:not(.od-palette-chip) { + font-family: var(--font-sans); + padding: 11px 16px; + border-radius: 10px; + border: none; + cursor: pointer; + font-size: 14px; + font-weight: 500; + background: var(--od-blue); + color: var(--od-bezel); + transition: background-color 80ms ease, transform 80ms ease, border-color 80ms ease; + box-shadow: none; +} + +.od-root button:not(.od-palette-chip):hover:not(:disabled) { + background-color: var(--od-blue-deep); + transform: translateY(-1px); +} + +.od-root button:not(.od-palette-chip):active:not(:disabled) { + transform: none; +} + +.od-root button.secondary { + background: transparent; + color: var(--fg1); + border: var(--bw-hair) solid var(--od-ink-line); + box-shadow: none; +} + +.od-root button.secondary:hover:not(:disabled) { + background-color: var(--od-paper-3); + border-color: var(--od-ink); + color: var(--fg1); +} + +.od-root button:not(.od-palette-chip):disabled { + background-color: var(--disabled-text-color, var(--fg4)); + color: var(--card-background-color, var(--od-white)); + border: none; + opacity: 1; + cursor: not-allowed; + transform: none; +} + +.od-details button { + margin-top: 8px; +} + +/* ---------- Autocomplete ---------- */ +.od-autocomplete { + position: absolute; + left: 0; + top: 0; + z-index: 50; + list-style: none; + padding: 6px 0; + margin: 6px 0 0; + max-height: min(280px, 40vh); + overflow-y: auto; + background: var(--od-paper); + border: var(--bw-hair) solid var(--od-paper-line); + border-radius: var(--rad-3); + box-shadow: 0 12px 32px rgba(11, 15, 18, 0.16); +} + +.od-autocomplete li { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + padding: 8px 14px; + font-family: var(--font-mono); + font-size: 12px; + color: var(--fg1); + cursor: pointer; +} + +.od-autocomplete .od-ac-id { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.od-autocomplete .od-ac-val { + flex: 0 1 auto; + max-width: 55%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--fg3); + font-size: 11px; + text-align: right; +} + +.od-autocomplete li:hover, +.od-autocomplete li.active { + background-color: var(--od-blue-wash); + color: var(--fg1); +} + +.od-autocomplete li:hover .od-ac-val, +.od-autocomplete li.active .od-ac-val { + color: var(--fg3); +} + +/* ---------- Service Details Section ---------- */ +.od-details { + margin-top: auto; + flex-shrink: 0; +} + +.od-details summary { + cursor: pointer; + font-weight: 600; + font-size: 13px; + padding: 8px 0; + color: var(--fg2); + user-select: none; +} + +.od-details summary:hover { + color: var(--od-blue-ink); +} + +.od-export { + margin-top: 8px; + font-family: var(--font-mono); + font-size: 11px; + white-space: pre-wrap; + word-break: break-word; + max-height: 140px; + overflow: auto; + padding: 10px; + background: var(--od-white); + border: var(--bw-hair) solid var(--od-paper-line); + border-radius: var(--rad-2); + color: var(--fg2); +} + +/* ---------- Preview Grid backdrop ---------- */ +.od-preview-frame { + position: relative; + flex: 1; + background: var(--od-paper-3); + border-radius: var(--rad-2); + min-height: 0; + display: flex; + justify-content: center; + align-items: center; + overflow: hidden; +} + +.od-preview-frame::before { + content: ''; + position: absolute; + inset: 0; + z-index: 0; + pointer-events: none; + background: + repeating-linear-gradient( + 0deg, + var(--od-paper-line) 0 1px, + transparent 1px 20px + ), + repeating-linear-gradient( + 90deg, + var(--od-paper-line) 0 1px, + transparent 1px 20px + ); + opacity: 0.45; +} + +.od-preview-frame.od-combined-frame { + flex: 1; + display: grid; + grid-template: 1fr / 1fr; + justify-items: center; + align-items: start; + min-height: 0; + height: 100%; +} + +.od-preview-frame.od-combined-frame > .od-preview-placeholder, +.od-preview-frame.od-combined-frame > #od-preview-img, +.od-preview-frame.od-combined-frame > #od-sketch { + grid-area: 1 / 1; + justify-self: center; + align-self: start; +} + +.od-preview-frame.od-combined-frame > .od-preview-placeholder { + z-index: 1; + padding: 16px; + text-align: center; + align-self: center; + pointer-events: none; + font-size: 13px; + color: var(--fg3); +} + +.od-preview-frame.od-combined-frame > #od-preview-img { + z-index: 2; + width: var(--od-preview-w, auto); + height: var(--od-preview-h, auto); + max-width: none; + max-height: none; + object-fit: contain; + image-rendering: pixelated; + box-shadow: 0 4px 24px rgba(11, 15, 18, 0.08); + border: var(--bw-hair) solid var(--od-paper-line); + display: none; +} + +.od-preview-frame.od-combined-frame > #od-sketch { + z-index: 3; + box-sizing: border-box; + width: var(--od-preview-w, auto); + height: var(--od-preview-h, auto); + display: block; + border-radius: var(--rad-1); + box-shadow: 0 4px 16px rgba(11, 15, 18, 0.06); + border: var(--bw-hair) solid var(--od-paper-line); + touch-action: none; +} + +.od-preview-frame.od-combined-frame.pvm-sketch > #od-preview-img { + display: none; + pointer-events: none; +} + +.od-preview-frame.od-combined-frame.pvm-sketch > #od-sketch { + opacity: 1; + pointer-events: auto; +} + +.od-preview-frame.od-combined-frame.pvm-overlay > #od-preview-img { + display: block; + pointer-events: none; +} + +.od-preview-frame.od-combined-frame.pvm-overlay > #od-sketch { + opacity: 1; + pointer-events: auto; +} + +.od-preview-frame.od-combined-frame.pvm-ha > #od-preview-img { + display: block; + z-index: 4; + pointer-events: auto; +} + +.od-preview-frame.od-combined-frame.pvm-ha > #od-sketch { + opacity: 0; + pointer-events: none; +} + +.od-preview-frame.od-combined-frame.pvm-split { + gap: 6px 10px; + align-items: center; + justify-items: center; + min-height: min(320px, 42vh); +} + +.od-preview-frame.od-combined-frame.pvm-split.od-split-cols { + grid-template-columns: 1fr 1fr; + grid-template-rows: auto minmax(0, 1fr); +} + +.od-preview-frame.od-combined-frame.pvm-split.od-split-rows { + grid-template-columns: 1fr; + grid-template-rows: auto minmax(0, 1fr) auto minmax(0, 1fr); +} + +.od-preview-frame.od-combined-frame.pvm-split.od-split-cols > .od-split-label-sketch { + display: block; + grid-column: 1; + grid-row: 1; + justify-self: center; +} + +.od-preview-frame.od-combined-frame.pvm-split.od-split-cols > .od-split-label-ha { + display: block; + grid-column: 2; + grid-row: 1; + justify-self: center; +} + +.od-preview-frame.od-combined-frame.pvm-split.od-split-cols > #od-sketch { + grid-column: 1; + grid-row: 2; + justify-self: center; + align-self: center; + opacity: 1; + pointer-events: auto; +} + +.od-preview-frame.od-combined-frame.pvm-split.od-split-cols > #od-preview-img { + grid-column: 2; + grid-row: 2; + justify-self: center; + align-self: center; + display: block; + pointer-events: none; +} + +.od-preview-frame.od-combined-frame.pvm-split.od-split-rows > .od-split-label-sketch { + display: block; + grid-column: 1; + grid-row: 1; +} + +.od-preview-frame.od-combined-frame.pvm-split.od-split-rows > #od-sketch { + grid-column: 1; + grid-row: 2; + justify-self: center; + align-self: center; + opacity: 1; + pointer-events: auto; +} + +.od-preview-frame.od-combined-frame.pvm-split.od-split-rows > .od-split-label-ha { + display: block; + grid-column: 1; + grid-row: 3; +} + +.od-preview-frame.od-combined-frame.pvm-split.od-split-rows > #od-preview-img { + grid-column: 1; + grid-row: 4; + justify-self: center; + align-self: center; + display: block; + pointer-events: none; +} + +.od-preview-frame.od-combined-frame.pvm-split > .od-preview-placeholder { + grid-column: 1 / -1; + grid-row: 1 / -1; +} + +/* ---------- Toasts ---------- */ +.od-toast { + position: fixed; + bottom: 24px; + right: 24px; + max-width: min(440px, 90vw); + padding: 14px 20px; + border-radius: var(--rad-3); + background: var(--od-white); + border: var(--bw-hair) solid var(--od-paper-line); + box-shadow: var(--shadow-2); + z-index: 1000; + font-weight: 500; + color: var(--fg1); + transition: transform 120ms ease; + animation: od-toast-in 200ms ease; +} + +.od-toast.err { + border-color: var(--od-error); + color: var(--od-error); + background: color-mix(in oklab, var(--od-error) 6%, var(--od-white)); +} + +@keyframes od-toast-in { + from { + transform: translateY(12px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +/* ---------- Mobile layout ---------- */ +@media (max-width: 768px) { + .od-workspace-tabs { + display: flex; + flex-shrink: 0; + } + + .od-root.od-tab-yaml .od-col-edit { + display: flex; + } + + .od-root.od-tab-yaml .od-col-visual { + display: none; + } + + .od-root.od-tab-visual .od-col-edit { + display: none; + } + + .od-root.od-tab-visual .od-col-visual { + display: flex; + } + + .od-root.od-tab-visual .od-export-panel { + display: none; + } + + .od-root.od-tab-export .od-col-edit { + display: none; + } + + .od-root.od-tab-export .od-col-visual { + display: flex; + } + + .od-root.od-tab-export .od-visual-stack { + display: none; + } + + .od-root.od-tab-export .od-export-panel { + display: block; + flex: 1; + border: none; + margin: 0; + } + + .od-root:not(.od-tab-yaml):not(.od-tab-visual):not(.od-tab-export) { + /* default first paint: yaml tab */ + } + + .od-root { + padding: 8px; + gap: 8px; + min-height: 100%; + } + + .od-root.od-tab-yaml { + /* ensure default mobile tab */ + } + + .od-card { + padding: 12px; + border-radius: var(--rad-2); + } + + .od-card h2 { + font-size: 0.95rem; + margin-bottom: 8px; + } + + .od-toolbar.od-card { + padding: 10px 12px; + } + + .od-main { + gap: 8px; + } + + .od-col.od-col-edit { + min-height: min(44vh, 380px); + } + + .od-col-edit .od-actions-sticky { + position: sticky; + bottom: 0; + z-index: 20; + background: var(--od-paper); + margin: 0 -12px -12px; + padding: 8px 12px 12px; + border-top: var(--bw-hair) solid var(--od-paper-line); + box-shadow: 0 -4px 12px rgba(11, 15, 18, 0.06); + } + + .od-actions-tools { + margin-left: 0; + } + + .od-actions-bar { + gap: 6px; + } + + .od-payload-ta { + min-height: min(220px, 36vh); + font-size: 12px; + } + + .od-actions-bar button { + padding: 8px 11px; + font-size: 12px; + } + + .od-visual-layout { + gap: 8px; + } + + .od-preview-head { + flex-direction: column; + align-items: flex-start; + gap: 6px; + } + + .od-color-swatches { + margin-left: 0; + width: 100%; + flex-wrap: wrap; + } + + .od-preview-modes { + width: 100%; + justify-content: flex-start; + } + + .od-palette { + flex-wrap: nowrap; + overflow-x: auto; + overflow-y: hidden; + max-height: none; + padding-bottom: 6px; + margin-right: -4px; + scrollbar-width: thin; + } + + .od-palette-chip { + flex-shrink: 0; + } + + .od-visual-panel { + padding: 10px; + } + + .od-preview-frame.od-combined-frame { + min-height: min(240px, 38vh); + } + + .od-details summary { + font-size: 12px; + } + + .od-toast { + left: 12px; + right: 12px; + bottom: 12px; + max-width: none; + } +} + +@media (max-width: 420px) { + .od-virtual-size { + grid-template-columns: auto 1fr auto 1fr; + width: 100%; + min-width: 0; + } + + .od-virtual-size input { + width: 100%; + min-width: 0; + } + + .od-actions-bar { + gap: 6px; + } + + .od-actions-group { + gap: 6px; + } + + .od-actions-bar button { + padding: 8px 9px; + font-size: 11px; + } +} diff --git a/custom_components/opendisplay/designer/frontend/app/yaml_gutter.js b/custom_components/opendisplay/designer/frontend/app/yaml_gutter.js new file mode 100644 index 0000000..c1bbaa1 --- /dev/null +++ b/custom_components/opendisplay/designer/frontend/app/yaml_gutter.js @@ -0,0 +1,118 @@ +/** + * Line-number gutter + diagnostic markers for the YAML editor. + */ + +/** + * @param {string} text + * @param {number} itemIndex + * @returns {{ line: number; offset: number } | null} + */ +export function findPayloadItemLineStart(text, itemIndex) { + const lines = String(text || '').split('\n'); + let item = -1; + for (let i = 0; i < lines.length; i += 1) { + if (/^\s*-\s+type\s*:/.test(lines[i])) { + item += 1; + if (item === itemIndex) { + let offset = 0; + for (let j = 0; j < i; j += 1) offset += lines[j].length + 1; + return { line: i, offset }; + } + } + } + return null; +} + +/** + * @param {string} text + * @param {Array<{ level: string; message: string }>} diags + * @param {string | null} parseError + */ +export function collectGutterMarks(text, diags, parseError) { + const lines = String(text || '').split('\n'); + /** @type {Set} */ + const err = new Set(); + /** @type {Set} */ + const warn = new Set(); + + if (parseError) { + const m = parseError.match(/line\s+(\d+)/i); + if (m) { + const n = parseInt(m[1], 10); + if (Number.isFinite(n) && n > 0) err.add(n - 1); + } else { + err.add(0); + } + } + + for (const d of diags || []) { + const m = String(d.message || '').match(/Item\s+(\d+)/i); + if (!m) continue; + const idx = parseInt(m[1], 10) - 1; + if (!Number.isFinite(idx) || idx < 0) continue; + const pos = findPayloadItemLineStart(text, idx); + if (!pos) continue; + if (d.level === 'warn') warn.add(pos.line); + else err.add(pos.line); + } + + return { lineCount: Math.max(1, lines.length), err, warn }; +} + +/** + * @param {HTMLTextAreaElement} ta + * @param {HTMLElement} gutter + * @param {string} text + * @param {Array<{ level: string; message: string }>} diags + * @param {string | null} parseError + */ +export function syncYamlGutter(ta, gutter, text, diags, parseError) { + const { lineCount, err, warn } = collectGutterMarks(text, diags, parseError); + const frag = document.createDocumentFragment(); + for (let i = 0; i < lineCount; i += 1) { + const row = document.createElement('div'); + row.className = 'od-gutter-line'; + row.textContent = String(i + 1); + if (err.has(i)) row.classList.add('od-gutter-err'); + else if (warn.has(i)) row.classList.add('od-gutter-warn'); + frag.appendChild(row); + } + gutter.replaceChildren(frag); + gutter.scrollTop = ta.scrollTop; +} + +/** + * @param {HTMLElement} shell + * @param {HTMLTextAreaElement} ta + * @param {() => { text: string; diags: Array<{ level: string; message: string }>; parseError: string | null }} getState + */ +export function setupYamlGutter(shell, ta, getState) { + const gutter = shell.querySelector('.od-editor-gutter'); + if (!gutter) return { refresh: () => {}, scrollToItem: () => {} }; + + const refresh = () => { + const { text, diags, parseError } = getState(); + syncYamlGutter(ta, /** @type {HTMLElement} */ (gutter), text, diags, parseError); + }; + + ta.addEventListener('scroll', () => { + gutter.scrollTop = ta.scrollTop; + }); + + return { + refresh, + /** @param {number} itemIndex */ + scrollToItem(itemIndex) { + const { text } = getState(); + const pos = findPayloadItemLineStart(text, itemIndex); + if (!pos) return; + ta.focus(); + ta.setSelectionRange(pos.offset, pos.offset); + const cs = getComputedStyle(ta); + const lh = parseFloat(cs.lineHeight) || parseFloat(cs.fontSize) * 1.5 || 20; + ta.scrollTop = Math.max(0, pos.line * lh - ta.clientHeight / 3); + gutter.scrollTop = ta.scrollTop; + refresh(); + }, + }; +} diff --git a/custom_components/opendisplay/designer/frontend/app/yaml_util.js b/custom_components/opendisplay/designer/frontend/app/yaml_util.js new file mode 100644 index 0000000..e250d53 --- /dev/null +++ b/custom_components/opendisplay/designer/frontend/app/yaml_util.js @@ -0,0 +1,154 @@ +import yaml from '../vendor/js-yaml.mjs'; + +/** Lines that are not part of a drawcustom payload list item. */ +const PAYLOAD_GARBAGE_LINE = + /^(?:\/?api\/|https?:\/\/|\}?\s*from\s+['"]|import\s+)/i; + +/** + * Normalize editor text: extract a pasted payload block and drop stray lines + * (e.g. API URLs accidentally appended after the list). + * @param {string} text + */ +export function normalizePayloadYamlInput(text) { + let src = String(text || '').replace(/\r\n/g, '\n').trim(); + if (!src) return ''; + + if (/^\s*action\s*:/im.test(src)) { + const m = src.match(/^\s*payload\s*:\s*\n([\s\S]*)$/im); + if (m) { + src = m[1].replace(/^\s{2}/gm, '').trim(); + } + } + + const lines = src.split('\n'); + const out = []; + let inList = false; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) { + if (inList) out.push(line); + continue; + } + const isItem = /^\s*-\s/.test(line); + const isField = /^\s+[\w-]+\s*:/.test(line); + if (isItem) { + inList = true; + out.push(line); + } else if (inList && isField) { + out.push(line); + } else if (inList) { + if (PAYLOAD_GARBAGE_LINE.test(trimmed)) break; + break; + } else { + out.push(line); + } + } + return out.join('\n').trim(); +} + +/** + * @param {unknown[]} payload + */ +export function dumpPayloadYaml(payload) { + return yaml.dump(payload, { lineWidth: -1, indent: 2, noRefs: true }); +} + +/** @param {unknown} value */ +export function formatYamlScalar(value) { + if (typeof value === 'string') { + if ( + value === '' || + /\r|\n/.test(value) || + /^\s|\s$/.test(value) || + /\{\{/.test(value) || + /\bstates\s*\(/.test(value) || + /\bstate_attr\s*\(/.test(value) || + /\bis_state\s*\(/.test(value) || + /[:#'"[\]{}]|^- /.test(value) || + /^(?:true|false|null|yes|no|on|off|~\s*|\*[\w-]+)$/i.test(value) || + /^[-+]?\d+(?:\.\d+)?$/i.test(value) + ) { + return JSON.stringify(value); + } + return value; + } + if (Array.isArray(value) || (typeof value === 'object' && value !== null)) { + return JSON.stringify(value); + } + if (value === null || value === undefined) { + return 'null'; + } + return String(value); +} + +/** + * @param {unknown[]} payloadItems + * @param {{ indent?: string }} [opts] + */ +function formatPayloadYamlLines(payloadItems, opts = {}) { + const indent = opts.indent ?? ''; + const fieldIndent = `${indent} `; + const lines = []; + if (!Array.isArray(payloadItems) || payloadItems.length === 0) { + return [`${indent}[]`]; + } + for (let i = 0; i < payloadItems.length; i += 1) { + const item = payloadItems[i]; + if (!item || typeof item !== 'object' || Array.isArray(item)) { + throw new Error(`payload[${i}] must be an object`); + } + const o = /** @type {Record} */ (item); + const t = o.type; + if (typeof t !== 'string' || !t) { + throw new Error(`payload[${i}] missing type`); + } + lines.push(`${indent}- type: ${formatYamlScalar(t)}`); + for (const [key, val] of Object.entries(o)) { + if (key === 'type') continue; + lines.push(`${fieldIndent}${key}: ${formatYamlScalar(val)}`); + } + } + return lines; +} + +/** + * Editor payload list (blueprint-style quoting for template values). + * @param {unknown[]} payloadItems + */ +export function formatPayloadYamlList(payloadItems) { + return formatPayloadYamlLines(payloadItems).join('\n'); +} + +/** + * @param {unknown[]} payloadItems + */ +export function formatPayloadYamlBlock(payloadItems) { + const lines = ['payload:']; + if (!Array.isArray(payloadItems) || payloadItems.length === 0) { + lines.push(' []'); + return lines.join('\n'); + } + lines.push(...formatPayloadYamlLines(payloadItems, { indent: ' ' })); + return lines.join('\n'); +} + +/** + * Full automation snippet (copy-paste into YAML automations/scripts). + */ +export function buildServiceCallSnippet(deviceId, dataFields, payloadYamlBlock) { + const { background, rotate, dither, refresh_type, dry_run } = dataFields; + const lines = [ + 'action: opendisplay.drawcustom', + 'target:', + ` device_id: ${JSON.stringify(deviceId)}`, + 'data:', + ` background: ${formatYamlScalar(background)}`, + ` rotate: ${rotate}`, + ` dither: ${formatYamlScalar(dither)}`, + ` refresh_type: ${formatYamlScalar(refresh_type)}`, + ` dry-run: ${dry_run ? 'true' : 'false'}`, + ]; + const payloadLines = payloadYamlBlock.split('\n').map((l) => ` ${l}`); + lines.push(...payloadLines); + return lines.join('\n'); +} diff --git a/custom_components/opendisplay/designer/frontend/panel/opendisplay-designer-panel.js b/custom_components/opendisplay/designer/frontend/panel/opendisplay-designer-panel.js new file mode 100644 index 0000000..cd71ac1 --- /dev/null +++ b/custom_components/opendisplay/designer/frontend/panel/opendisplay-designer-panel.js @@ -0,0 +1,45 @@ +import { mountDesigner } from '../app/main.js'; + +const TAG = 'opendisplay-designer-panel'; + +class OpenDisplayDesignerPanel extends HTMLElement { + constructor() { + super(); + this._hass = null; + this._teardown = null; + } + + set hass(value) { + this._hass = value; + if (this._teardown?.setHass) { + this._teardown.setHass(value); + } + } + + get hass() { + return this._hass; + } + + connectedCallback() { + this.style.display = 'block'; + this.style.height = '100%'; + this.style.minHeight = '0'; + this.style.overflow = 'hidden'; + if (!this._teardown) { + this._teardown = mountDesigner(this, this._hass); + } else if (this._teardown.setHass) { + this._teardown.setHass(this._hass); + } + } + + disconnectedCallback() { + if (this._teardown?.destroy) { + this._teardown.destroy(); + this._teardown = null; + } + } +} + +if (!customElements.get(TAG)) { + customElements.define(TAG, OpenDisplayDesignerPanel); +} diff --git a/custom_components/opendisplay/designer/frontend/vendor/js-yaml.mjs b/custom_components/opendisplay/designer/frontend/vendor/js-yaml.mjs new file mode 100644 index 0000000..257f811 --- /dev/null +++ b/custom_components/opendisplay/designer/frontend/vendor/js-yaml.mjs @@ -0,0 +1,9 @@ +/** + * Bundled by jsDelivr using Rollup v2.79.2 and Terser v5.39.0. + * Original file: /npm/js-yaml@4.1.0/dist/js-yaml.mjs + * + * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files + */ +/*! js-yaml 4.1.0 https://github.com/nodeca/js-yaml @license MIT */ +function e(e){return null==e}var t={isNothing:e,isObject:function(e){return"object"==typeof e&&null!==e},toArray:function(t){return Array.isArray(t)?t:e(t)?[]:[t]},repeat:function(e,t){var n,i="";for(n=0;nl&&(t=i-l+(o=" ... ").length),n-i>l&&(n=i+l-(a=" ...").length),{str:o+e.slice(t,n).replace(/\t/g,"→")+a,pos:i-t+o.length}}function a(e,n){return t.repeat(" ",n-e.length)+e}var l=function(e,n){if(n=Object.create(n||null),!e.buffer)return null;n.maxLength||(n.maxLength=79),"number"!=typeof n.indent&&(n.indent=1),"number"!=typeof n.linesBefore&&(n.linesBefore=3),"number"!=typeof n.linesAfter&&(n.linesAfter=2);for(var i,r=/\r?\n|\r|\0/g,l=[0],c=[],s=-1;i=r.exec(e.buffer);)c.push(i.index),l.push(i.index+i[0].length),e.position<=i.index&&s<0&&(s=l.length-2);s<0&&(s=l.length-1);var u,p,f="",d=Math.min(e.line+n.linesAfter,c.length).toString().length,h=n.maxLength-(n.indent+d+3);for(u=1;u<=n.linesBefore&&!(s-u<0);u++)p=o(e.buffer,l[s-u],c[s-u],e.position-(l[s]-l[s-u]),h),f=t.repeat(" ",n.indent)+a((e.line-u+1).toString(),d)+" | "+p.str+"\n"+f;for(p=o(e.buffer,l[s],c[s],e.position,h),f+=t.repeat(" ",n.indent)+a((e.line+1).toString(),d)+" | "+p.str+"\n",f+=t.repeat("-",n.indent+d+3+p.pos)+"^\n",u=1;u<=n.linesAfter&&!(s+u>=c.length);u++)p=o(e.buffer,l[s+u],c[s+u],e.position-(l[s]-l[s+u]),h),f+=t.repeat(" ",n.indent)+a((e.line+u+1).toString(),d)+" | "+p.str+"\n";return f.replace(/\n$/,"")},c=["kind","multi","resolve","construct","instanceOf","predicate","represent","representName","defaultStyle","styleAliases"],s=["scalar","sequence","mapping"];var u=function(e,t){if(t=t||{},Object.keys(t).forEach((function(t){if(-1===c.indexOf(t))throw new r('Unknown option "'+t+'" is met in definition of "'+e+'" YAML type.')})),this.options=t,this.tag=e,this.kind=t.kind||null,this.resolve=t.resolve||function(){return!0},this.construct=t.construct||function(e){return e},this.instanceOf=t.instanceOf||null,this.predicate=t.predicate||null,this.represent=t.represent||null,this.representName=t.representName||null,this.defaultStyle=t.defaultStyle||null,this.multi=t.multi||!1,this.styleAliases=function(e){var t={};return null!==e&&Object.keys(e).forEach((function(n){e[n].forEach((function(e){t[String(e)]=n}))})),t}(t.styleAliases||null),-1===s.indexOf(this.kind))throw new r('Unknown kind "'+this.kind+'" is specified for "'+e+'" YAML type.')};function p(e,t){var n=[];return e[t].forEach((function(e){var t=n.length;n.forEach((function(n,i){n.tag===e.tag&&n.kind===e.kind&&n.multi===e.multi&&(t=i)})),n[t]=e})),n}function f(e){return this.extend(e)}f.prototype.extend=function(e){var t=[],n=[];if(e instanceof u)n.push(e);else if(Array.isArray(e))n=n.concat(e);else{if(!e||!Array.isArray(e.implicit)&&!Array.isArray(e.explicit))throw new r("Schema.extend argument should be a Type, [ Type ], or a schema definition ({ implicit: [...], explicit: [...] })");e.implicit&&(t=t.concat(e.implicit)),e.explicit&&(n=n.concat(e.explicit))}t.forEach((function(e){if(!(e instanceof u))throw new r("Specified list of YAML types (or a single Type object) contains a non-Type object.");if(e.loadKind&&"scalar"!==e.loadKind)throw new r("There is a non-scalar type in the implicit list of a schema. Implicit resolving of such types is not supported.");if(e.multi)throw new r("There is a multi type in the implicit list of a schema. Multi tags can only be listed as explicit.")})),n.forEach((function(e){if(!(e instanceof u))throw new r("Specified list of YAML types (or a single Type object) contains a non-Type object.")}));var i=Object.create(f.prototype);return i.implicit=(this.implicit||[]).concat(t),i.explicit=(this.explicit||[]).concat(n),i.compiledImplicit=p(i,"implicit"),i.compiledExplicit=p(i,"explicit"),i.compiledTypeMap=function(){var e,t,n={scalar:{},sequence:{},mapping:{},fallback:{},multi:{scalar:[],sequence:[],mapping:[],fallback:[]}};function i(e){e.multi?(n.multi[e.kind].push(e),n.multi.fallback.push(e)):n[e.kind][e.tag]=n.fallback[e.tag]=e}for(e=0,t=arguments.length;e=0?"0b"+e.toString(2):"-0b"+e.toString(2).slice(1)},octal:function(e){return e>=0?"0o"+e.toString(8):"-0o"+e.toString(8).slice(1)},decimal:function(e){return e.toString(10)},hexadecimal:function(e){return e>=0?"0x"+e.toString(16).toUpperCase():"-0x"+e.toString(16).toUpperCase().slice(1)}},defaultStyle:"decimal",styleAliases:{binary:[2,"bin"],octal:[8,"oct"],decimal:[10,"dec"],hexadecimal:[16,"hex"]}}),C=new RegExp("^(?:[-+]?(?:[0-9][0-9_]*)(?:\\.[0-9_]*)?(?:[eE][-+]?[0-9]+)?|\\.[0-9_]+(?:[eE][-+]?[0-9]+)?|[-+]?\\.(?:inf|Inf|INF)|\\.(?:nan|NaN|NAN))$");var x=/^[-+]?[0-9]+e/;var I=new u("tag:yaml.org,2002:float",{kind:"scalar",resolve:function(e){return null!==e&&!(!C.test(e)||"_"===e[e.length-1])},construct:function(e){var t,n;return n="-"===(t=e.replace(/_/g,"").toLowerCase())[0]?-1:1,"+-".indexOf(t[0])>=0&&(t=t.slice(1)),".inf"===t?1===n?Number.POSITIVE_INFINITY:Number.NEGATIVE_INFINITY:".nan"===t?NaN:n*parseFloat(t,10)},predicate:function(e){return"[object Number]"===Object.prototype.toString.call(e)&&(e%1!=0||t.isNegativeZero(e))},represent:function(e,n){var i;if(isNaN(e))switch(n){case"lowercase":return".nan";case"uppercase":return".NAN";case"camelcase":return".NaN"}else if(Number.POSITIVE_INFINITY===e)switch(n){case"lowercase":return".inf";case"uppercase":return".INF";case"camelcase":return".Inf"}else if(Number.NEGATIVE_INFINITY===e)switch(n){case"lowercase":return"-.inf";case"uppercase":return"-.INF";case"camelcase":return"-.Inf"}else if(t.isNegativeZero(e))return"-0.0";return i=e.toString(10),x.test(i)?i.replace("e",".e"):i},defaultStyle:"lowercase"}),O=y.extend({implicit:[b,A,k,I]}),S=O,j=new RegExp("^([0-9][0-9][0-9][0-9])-([0-9][0-9])-([0-9][0-9])$"),T=new RegExp("^([0-9][0-9][0-9][0-9])-([0-9][0-9]?)-([0-9][0-9]?)(?:[Tt]|[ \\t]+)([0-9][0-9]?):([0-9][0-9]):([0-9][0-9])(?:\\.([0-9]*))?(?:[ \\t]*(Z|([-+])([0-9][0-9]?)(?::([0-9][0-9]))?))?$");var N=new u("tag:yaml.org,2002:timestamp",{kind:"scalar",resolve:function(e){return null!==e&&(null!==j.exec(e)||null!==T.exec(e))},construct:function(e){var t,n,i,r,o,a,l,c,s=0,u=null;if(null===(t=j.exec(e))&&(t=T.exec(e)),null===t)throw new Error("Date resolve error");if(n=+t[1],i=+t[2]-1,r=+t[3],!t[4])return new Date(Date.UTC(n,i,r));if(o=+t[4],a=+t[5],l=+t[6],t[7]){for(s=t[7].slice(0,3);s.length<3;)s+="0";s=+s}return t[9]&&(u=6e4*(60*+t[10]+ +(t[11]||0)),"-"===t[9]&&(u=-u)),c=new Date(Date.UTC(n,i,r,o,a,l,s)),u&&c.setTime(c.getTime()-u),c},instanceOf:Date,represent:function(e){return e.toISOString()}});var F=new u("tag:yaml.org,2002:merge",{kind:"scalar",resolve:function(e){return"<<"===e||null===e}}),M="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=\n\r";var L=new u("tag:yaml.org,2002:binary",{kind:"scalar",resolve:function(e){if(null===e)return!1;var t,n,i=0,r=e.length,o=M;for(n=0;n64)){if(t<0)return!1;i+=6}return i%8==0},construct:function(e){var t,n,i=e.replace(/[\r\n=]/g,""),r=i.length,o=M,a=0,l=[];for(t=0;t>16&255),l.push(a>>8&255),l.push(255&a)),a=a<<6|o.indexOf(i.charAt(t));return 0===(n=r%4*6)?(l.push(a>>16&255),l.push(a>>8&255),l.push(255&a)):18===n?(l.push(a>>10&255),l.push(a>>2&255)):12===n&&l.push(a>>4&255),new Uint8Array(l)},predicate:function(e){return"[object Uint8Array]"===Object.prototype.toString.call(e)},represent:function(e){var t,n,i="",r=0,o=e.length,a=M;for(t=0;t>18&63],i+=a[r>>12&63],i+=a[r>>6&63],i+=a[63&r]),r=(r<<8)+e[t];return 0===(n=o%3)?(i+=a[r>>18&63],i+=a[r>>12&63],i+=a[r>>6&63],i+=a[63&r]):2===n?(i+=a[r>>10&63],i+=a[r>>4&63],i+=a[r<<2&63],i+=a[64]):1===n&&(i+=a[r>>2&63],i+=a[r<<4&63],i+=a[64],i+=a[64]),i}}),E=Object.prototype.hasOwnProperty,_=Object.prototype.toString;var D=new u("tag:yaml.org,2002:omap",{kind:"sequence",resolve:function(e){if(null===e)return!0;var t,n,i,r,o,a=[],l=e;for(t=0,n=l.length;t>10),56320+(e-65536&1023))}for(var ne=new Array(256),ie=new Array(256),re=0;re<256;re++)ne[re]=ee(re)?1:0,ie[re]=ee(re);function oe(e,t){this.input=e,this.filename=t.filename||null,this.schema=t.schema||B,this.onWarning=t.onWarning||null,this.legacy=t.legacy||!1,this.json=t.json||!1,this.listener=t.listener||null,this.implicitTypes=this.schema.compiledImplicit,this.typeMap=this.schema.compiledTypeMap,this.length=e.length,this.position=0,this.line=0,this.lineStart=0,this.lineIndent=0,this.firstTabInLine=-1,this.documents=[]}function ae(e,t){var n={name:e.filename,buffer:e.input.slice(0,-1),position:e.position,line:e.line,column:e.position-e.lineStart};return n.snippet=l(n),new r(t,n)}function le(e,t){throw ae(e,t)}function ce(e,t){e.onWarning&&e.onWarning.call(null,ae(e,t))}var se={YAML:function(e,t,n){var i,r,o;null!==e.version&&le(e,"duplication of %YAML directive"),1!==n.length&&le(e,"YAML directive accepts exactly one argument"),null===(i=/^([0-9]+)\.([0-9]+)$/.exec(n[0]))&&le(e,"ill-formed argument of the YAML directive"),r=parseInt(i[1],10),o=parseInt(i[2],10),1!==r&&le(e,"unacceptable YAML version of the document"),e.version=n[0],e.checkLineBreaks=o<2,1!==o&&2!==o&&ce(e,"unsupported YAML version of the document")},TAG:function(e,t,n){var i,r;2!==n.length&&le(e,"TAG directive accepts exactly two arguments"),i=n[0],r=n[1],G.test(i)||le(e,"ill-formed tag handle (first argument) of the TAG directive"),K.call(e.tagMap,i)&&le(e,'there is a previously declared suffix for "'+i+'" tag handle'),V.test(r)||le(e,"ill-formed tag prefix (second argument) of the TAG directive");try{r=decodeURIComponent(r)}catch(t){le(e,"tag prefix is malformed: "+r)}e.tagMap[i]=r}};function ue(e,t,n,i){var r,o,a,l;if(t1&&(e.result+=t.repeat("\n",n-1))}function ye(e,t){var n,i,r=e.tag,o=e.anchor,a=[],l=!1;if(-1!==e.firstTabInLine)return!1;for(null!==e.anchor&&(e.anchorMap[e.anchor]=a),i=e.input.charCodeAt(e.position);0!==i&&(-1!==e.firstTabInLine&&(e.position=e.firstTabInLine,le(e,"tab characters must not be used in indentation")),45===i)&&z(e.input.charCodeAt(e.position+1));)if(l=!0,e.position++,he(e,!0,-1)&&e.lineIndent<=t)a.push(null),i=e.input.charCodeAt(e.position);else if(n=e.line,ve(e,t,3,!1,!0),a.push(e.result),he(e,!0,-1),i=e.input.charCodeAt(e.position),(e.line===n||e.lineIndent>t)&&0!==i)le(e,"bad indentation of a sequence entry");else if(e.lineIndentn?g=1:e.lineIndent===n?g=0:e.lineIndentn?g=1:e.lineIndent===n?g=0:e.lineIndentt)&&(y&&(a=e.line,l=e.lineStart,c=e.position),ve(e,t,4,!0,r)&&(y?g=e.result:m=e.result),y||(fe(e,f,d,h,g,m,a,l,c),h=g=m=null),he(e,!0,-1),s=e.input.charCodeAt(e.position)),(e.line===o||e.lineIndent>t)&&0!==s)le(e,"bad indentation of a mapping entry");else if(e.lineIndent=0))break;0===o?le(e,"bad explicit indentation width of a block scalar; it cannot be less than one"):u?le(e,"repeat of an indentation width identifier"):(p=n+o-1,u=!0)}if(Q(a)){do{a=e.input.charCodeAt(++e.position)}while(Q(a));if(35===a)do{a=e.input.charCodeAt(++e.position)}while(!H(a)&&0!==a)}for(;0!==a;){for(de(e),e.lineIndent=0,a=e.input.charCodeAt(e.position);(!u||e.lineIndentp&&(p=e.lineIndent),H(a))f++;else{if(e.lineIndent0){for(r=a,o=0;r>0;r--)(a=X(l=e.input.charCodeAt(++e.position)))>=0?o=(o<<4)+a:le(e,"expected hexadecimal character");e.result+=te(o),e.position++}else le(e,"unknown escape sequence");n=i=e.position}else H(l)?(ue(e,n,i,!0),me(e,he(e,!1,t)),n=i=e.position):e.position===e.lineStart&&ge(e)?le(e,"unexpected end of the document within a double quoted scalar"):(e.position++,i=e.position)}le(e,"unexpected end of the stream within a double quoted scalar")}(e,d)?y=!0:!function(e){var t,n,i;if(42!==(i=e.input.charCodeAt(e.position)))return!1;for(i=e.input.charCodeAt(++e.position),t=e.position;0!==i&&!z(i)&&!J(i);)i=e.input.charCodeAt(++e.position);return e.position===t&&le(e,"name of an alias node must contain at least one character"),n=e.input.slice(t,e.position),K.call(e.anchorMap,n)||le(e,'unidentified alias "'+n+'"'),e.result=e.anchorMap[n],he(e,!0,-1),!0}(e)?function(e,t,n){var i,r,o,a,l,c,s,u,p=e.kind,f=e.result;if(z(u=e.input.charCodeAt(e.position))||J(u)||35===u||38===u||42===u||33===u||124===u||62===u||39===u||34===u||37===u||64===u||96===u)return!1;if((63===u||45===u)&&(z(i=e.input.charCodeAt(e.position+1))||n&&J(i)))return!1;for(e.kind="scalar",e.result="",r=o=e.position,a=!1;0!==u;){if(58===u){if(z(i=e.input.charCodeAt(e.position+1))||n&&J(i))break}else if(35===u){if(z(e.input.charCodeAt(e.position-1)))break}else{if(e.position===e.lineStart&&ge(e)||n&&J(u))break;if(H(u)){if(l=e.line,c=e.lineStart,s=e.lineIndent,he(e,!1,-1),e.lineIndent>=t){a=!0,u=e.input.charCodeAt(e.position);continue}e.position=o,e.line=l,e.lineStart=c,e.lineIndent=s;break}}a&&(ue(e,r,o,!1),me(e,e.line-l),r=o=e.position,a=!1),Q(u)||(o=e.position+1),u=e.input.charCodeAt(++e.position)}return ue(e,r,o,!1),!!e.result||(e.kind=p,e.result=f,!1)}(e,d,1===i)&&(y=!0,null===e.tag&&(e.tag="?")):(y=!0,null===e.tag&&null===e.anchor||le(e,"alias node should not have any properties")),null!==e.anchor&&(e.anchorMap[e.anchor]=e.result)):0===g&&(y=c&&ye(e,h))),null===e.tag)null!==e.anchor&&(e.anchorMap[e.anchor]=e.result);else if("?"===e.tag){for(null!==e.result&&"scalar"!==e.kind&&le(e,'unacceptable node kind for ! tag; it should be "scalar", not "'+e.kind+'"'),s=0,u=e.implicitTypes.length;s"),null!==e.result&&f.kind!==e.kind&&le(e,"unacceptable node kind for !<"+e.tag+'> tag; it should be "'+f.kind+'", not "'+e.kind+'"'),f.resolve(e.result,e.tag)?(e.result=f.construct(e.result,e.tag),null!==e.anchor&&(e.anchorMap[e.anchor]=e.result)):le(e,"cannot resolve a node with !<"+e.tag+"> explicit tag")}return null!==e.listener&&e.listener("close",e),null!==e.tag||null!==e.anchor||y}function we(e){var t,n,i,r,o=e.position,a=!1;for(e.version=null,e.checkLineBreaks=e.legacy,e.tagMap=Object.create(null),e.anchorMap=Object.create(null);0!==(r=e.input.charCodeAt(e.position))&&(he(e,!0,-1),r=e.input.charCodeAt(e.position),!(e.lineIndent>0||37!==r));){for(a=!0,r=e.input.charCodeAt(++e.position),t=e.position;0!==r&&!z(r);)r=e.input.charCodeAt(++e.position);for(i=[],(n=e.input.slice(t,e.position)).length<1&&le(e,"directive name must not be less than one character in length");0!==r;){for(;Q(r);)r=e.input.charCodeAt(++e.position);if(35===r){do{r=e.input.charCodeAt(++e.position)}while(0!==r&&!H(r));break}if(H(r))break;for(t=e.position;0!==r&&!z(r);)r=e.input.charCodeAt(++e.position);i.push(e.input.slice(t,e.position))}0!==r&&de(e),K.call(se,n)?se[n](e,n,i):ce(e,'unknown document directive "'+n+'"')}he(e,!0,-1),0===e.lineIndent&&45===e.input.charCodeAt(e.position)&&45===e.input.charCodeAt(e.position+1)&&45===e.input.charCodeAt(e.position+2)?(e.position+=3,he(e,!0,-1)):a&&le(e,"directives end mark is expected"),ve(e,e.lineIndent-1,4,!1,!0),he(e,!0,-1),e.checkLineBreaks&&P.test(e.input.slice(o,e.position))&&ce(e,"non-ASCII line breaks are interpreted as content"),e.documents.push(e.result),e.position===e.lineStart&&ge(e)?46===e.input.charCodeAt(e.position)&&(e.position+=3,he(e,!0,-1)):e.position=55296&&i<=56319&&t+1=56320&&n<=57343?1024*(i-55296)+n-56320+65536:i}function Ye(e){return/^\n* /.test(e)}function Re(e,t,n,i,r,o,a,l){var c,s,u=0,p=null,f=!1,d=!1,h=-1!==i,g=-1,m=_e(s=Ue(e,0))&&s!==Oe&&!Ee(s)&&45!==s&&63!==s&&58!==s&&44!==s&&91!==s&&93!==s&&123!==s&&125!==s&&35!==s&&38!==s&&42!==s&&33!==s&&124!==s&&61!==s&&62!==s&&39!==s&&34!==s&&37!==s&&64!==s&&96!==s&&function(e){return!Ee(e)&&58!==e}(Ue(e,e.length-1));if(t||a)for(c=0;c=65536?c+=2:c++){if(!_e(u=Ue(e,c)))return 5;m=m&&qe(u,p,l),p=u}else{for(c=0;c=65536?c+=2:c++){if(10===(u=Ue(e,c)))f=!0,h&&(d=d||c-g-1>i&&" "!==e[g+1],g=c);else if(!_e(u))return 5;m=m&&qe(u,p,l),p=u}d=d||h&&c-g-1>i&&" "!==e[g+1]}return f||d?n>9&&Ye(e)?5:a?2===o?5:2:d?4:3:!m||a||r(e)?2===o?5:2:1}function Be(e,t,n,i,o){e.dump=function(){if(0===t.length)return 2===e.quotingType?'""':"''";if(!e.noCompatMode&&(-1!==je.indexOf(t)||Te.test(t)))return 2===e.quotingType?'"'+t+'"':"'"+t+"'";var a=e.indent*Math.max(1,n),l=-1===e.lineWidth?-1:Math.max(Math.min(e.lineWidth,40),e.lineWidth-a),c=i||e.flowLevel>-1&&n>=e.flowLevel;switch(Re(t,c,e.indent,l,(function(t){return function(e,t){var n,i;for(n=0,i=e.implicitTypes.length;n"+Ke(t,e.indent)+We(Me(function(e,t){var n,i,r=/(\n+)([^\n]*)/g,o=(l=e.indexOf("\n"),l=-1!==l?l:e.length,r.lastIndex=l,Pe(e.slice(0,l),t)),a="\n"===e[0]||" "===e[0];var l;for(;i=r.exec(e);){var c=i[1],s=i[2];n=" "===s[0],o+=c+(a||n||""===s?"":"\n")+Pe(s,t),a=n}return o}(t,l),a));case 5:return'"'+function(e){for(var t,n="",i=0,r=0;r=65536?r+=2:r++)i=Ue(e,r),!(t=Se[i])&&_e(i)?(n+=e[r],i>=65536&&(n+=e[r+1])):n+=t||Ne(i);return n}(t)+'"';default:throw new r("impossible error: invalid scalar style")}}()}function Ke(e,t){var n=Ye(e)?String(t):"",i="\n"===e[e.length-1];return n+(i&&("\n"===e[e.length-2]||"\n"===e)?"+":i?"":"-")+"\n"}function We(e){return"\n"===e[e.length-1]?e.slice(0,-1):e}function Pe(e,t){if(""===e||" "===e[0])return e;for(var n,i,r=/ [^ ]/g,o=0,a=0,l=0,c="";n=r.exec(e);)(l=n.index)-o>t&&(i=a>o?a:l,c+="\n"+e.slice(o,i),o=i+1),a=l;return c+="\n",e.length-o>t&&a>o?c+=e.slice(o,a)+"\n"+e.slice(a+1):c+=e.slice(o),c.slice(1)}function $e(e,t,n,i){var r,o,a,l="",c=e.tag;for(r=0,o=n.length;r tag resolver accepts not "'+s+'" style');i=c.represent[s](t,s)}e.dump=i}return!0}return!1}function Ve(e,t,n,i,o,a,l){e.tag=null,e.dump=n,Ge(e,n,!1)||Ge(e,n,!0);var c,s=xe.call(e.dump),u=i;i&&(i=e.flowLevel<0||e.flowLevel>t);var p,f,d="[object Object]"===s||"[object Array]"===s;if(d&&(f=-1!==(p=e.duplicates.indexOf(n))),(null!==e.tag&&"?"!==e.tag||f||2!==e.indent&&t>0)&&(o=!1),f&&e.usedDuplicates[p])e.dump="*ref_"+p;else{if(d&&f&&!e.usedDuplicates[p]&&(e.usedDuplicates[p]=!0),"[object Object]"===s)i&&0!==Object.keys(e.dump).length?(!function(e,t,n,i){var o,a,l,c,s,u,p="",f=e.tag,d=Object.keys(n);if(!0===e.sortKeys)d.sort();else if("function"==typeof e.sortKeys)d.sort(e.sortKeys);else if(e.sortKeys)throw new r("sortKeys must be a boolean or a function");for(o=0,a=d.length;o1024)&&(e.dump&&10===e.dump.charCodeAt(0)?u+="?":u+="? "),u+=e.dump,s&&(u+=Le(e,t)),Ve(e,t+1,c,!0,s)&&(e.dump&&10===e.dump.charCodeAt(0)?u+=":":u+=": ",p+=u+=e.dump));e.tag=f,e.dump=p||"{}"}(e,t,e.dump,o),f&&(e.dump="&ref_"+p+e.dump)):(!function(e,t,n){var i,r,o,a,l,c="",s=e.tag,u=Object.keys(n);for(i=0,r=u.length;i1024&&(l+="? "),l+=e.dump+(e.condenseFlow?'"':"")+":"+(e.condenseFlow?"":" "),Ve(e,t,a,!1,!1)&&(c+=l+=e.dump));e.tag=s,e.dump="{"+c+"}"}(e,t,e.dump),f&&(e.dump="&ref_"+p+" "+e.dump));else if("[object Array]"===s)i&&0!==e.dump.length?(e.noArrayIndent&&!l&&t>0?$e(e,t-1,e.dump,o):$e(e,t,e.dump,o),f&&(e.dump="&ref_"+p+e.dump)):(!function(e,t,n){var i,r,o,a="",l=e.tag;for(i=0,r=n.length;i",e.dump=c+" "+e.dump)}return!0}function Ze(e,t){var n,i,r=[],o=[];for(He(e,r,o),n=0,i=o.length;n bytes: + w = max(1, min(int(width), 4096)) + h = max(1, min(int(height), 4096)) + img = PILImage.new("RGB", (w, h), (255, 255, 255)) + buf = io.BytesIO() + img.save(buf, format="JPEG", quality=90) + return buf.getvalue() + + +def _placeholder_size(entry: OpenDisplayConfigEntry) -> tuple[int, int]: + try: + display = entry.runtime_data.device_config.displays[0] + w = int(display.pixel_width) + h = int(display.pixel_height) + if w > 0 and h > 0: + return w, h + except (AttributeError, IndexError, TypeError, ValueError): + pass + return _DEFAULT_PLACEHOLDER_SIZE + + +def _capability_device_id(entity: Any, entry: OpenDisplayConfigEntry) -> str: + registry_entry = getattr(entity, "registry_entry", None) + if registry_entry is not None and registry_entry.device_id: + return registry_entry.device_id + return resolve_device_id_for_entry(entity.hass, entry) or "" + + +def _refresh_capability_attributes(entity: Any, entry: OpenDisplayConfigEntry) -> dict[str, Any]: + try: + return build_capabilities( + entry, + _capability_device_id(entity, entry), + user_rotate_deg=0, + ) + except Exception: + _LOGGER.exception( + "Failed to build designer capabilities for %s", + getattr(entity, "entity_id", entity.unique_id), + ) + return {} + + +async def designer_on_entity_added(entity: Any, entry: OpenDisplayConfigEntry | None) -> None: + """Publish capability attrs and a white placeholder JPEG for the designer.""" + if entry is None: + return + entity._designer_capability_attributes = _refresh_capability_attributes(entity, entry) + if entity._image_bytes: + entity.async_write_ha_state() + return + width, height = _placeholder_size(entry) + try: + entity._image_bytes = await entity.hass.async_add_executor_job( + _blank_white_jpeg, width, height + ) + except Exception: + _LOGGER.exception("Failed to create placeholder image for %s", entity.entity_id) + entity.async_write_ha_state() + return + entity._attr_image_last_updated = dt_util.utcnow() + entity.async_write_ha_state() + + +def designer_extra_state_attributes(entity: Any, entry: OpenDisplayConfigEntry | None) -> dict[str, Any]: + if entry is None: + return {} + attrs = getattr(entity, "_designer_capability_attributes", None) + if not attrs: + attrs = _refresh_capability_attributes(entity, entry) + entity._designer_capability_attributes = attrs + return attrs diff --git a/custom_components/opendisplay/designer/panel.py b/custom_components/opendisplay/designer/panel.py new file mode 100644 index 0000000..fb656ff --- /dev/null +++ b/custom_components/opendisplay/designer/panel.py @@ -0,0 +1,116 @@ +"""HTTP views: static frontend for the OpenDisplay designer panel.""" + +from __future__ import annotations + +import asyncio +import logging +import mimetypes +from pathlib import Path +from typing import Any, Callable + +from aiohttp import web +from homeassistant.components.http import HomeAssistantView +from homeassistant.core import HomeAssistant + +from .const import DESIGNER_STATIC_URL + +_LOGGER = logging.getLogger(__name__) + +FRONTEND_DIR = Path(__file__).parent / "frontend" +PANEL_JS_REL = "panel/opendisplay-designer-panel.js" +# Bump when shipping frontend fixes so HA panel cache invalidates. +DESIGNER_FRONTEND_BUILD = "20260605z" + + +def get_frontend_cache_token() -> str: + panel_js = FRONTEND_DIR / PANEL_JS_REL + try: + return f"{DESIGNER_FRONTEND_BUILD}-{panel_js.stat().st_mtime_ns}" + except OSError: + return DESIGNER_FRONTEND_BUILD + + +def _append_cache_token(url: str) -> str: + sep = "&" if "?" in url else "?" + return f"{url}{sep}v={get_frontend_cache_token()}" + + +def get_panel_module_url() -> str: + return _append_cache_token(f"{DESIGNER_STATIC_URL}/{PANEL_JS_REL}") + + +async def _run_in_executor( + hass: HomeAssistant, func: Callable[..., Any], *args: Any +) -> Any: + if hasattr(hass, "async_add_executor_job"): + return await hass.async_add_executor_job(func, *args) + return await asyncio.to_thread(func, *args) + + +async def async_get_panel_module_url(hass: HomeAssistant) -> str: + return await _run_in_executor(hass, get_panel_module_url) + + +def _resolve_static_path(path: str) -> Path: + rel = Path(path) + candidate = (FRONTEND_DIR / rel).resolve() + candidate.relative_to(FRONTEND_DIR.resolve()) + return candidate + + +def _no_cache_headers() -> dict[str, str]: + return { + "Cache-Control": "no-cache, no-store, must-revalidate", + "Pragma": "no-cache", + "Expires": "0", + } + + +class OpenDisplayDesignerStaticView(HomeAssistantView): + """Serve designer panel JS/CSS and vendor assets.""" + + url = f"{DESIGNER_STATIC_URL}/{{path:.*}}" + name = "opendisplay:designer_static" + requires_auth = False + + def __init__(self, hass: HomeAssistant) -> None: + self.hass = hass + + async def get(self, request: web.Request, path: str) -> web.Response: + if ".." in path or path.startswith("/"): + return web.Response(status=403, text="Forbidden") + try: + file_path = await _run_in_executor(self.hass, _resolve_static_path, path) + except ValueError: + return web.Response(status=403, text="Forbidden") + if not file_path.is_file(): + return web.Response(status=404, text="Not found") + try: + data = await _run_in_executor(self.hass, file_path.read_bytes) + except OSError: + return web.Response(status=500, text="Error") + ctype, _ = mimetypes.guess_type(str(file_path)) + if path.endswith((".js", ".mjs")): + ctype = "application/javascript" + elif path.endswith(".css"): + ctype = "text/css" + if not ctype: + ctype = "application/octet-stream" + + charset = ( + "utf-8" + if ctype in ("application/javascript", "text/css") + else None + ) + if charset: + return web.Response( + body=data, + content_type=ctype, + charset=charset, + headers=_no_cache_headers(), + ) + return web.Response( + body=data, + content_type=ctype, + headers=_no_cache_headers(), + ) diff --git a/custom_components/opendisplay/image.py b/custom_components/opendisplay/image.py index 3d32703..c6f2204 100644 --- a/custom_components/opendisplay/image.py +++ b/custom_components/opendisplay/image.py @@ -10,6 +10,10 @@ from . import OpenDisplayConfigEntry from .const import SIGNAL_IMAGE_UPDATED from .coordinator import OpenDisplayCoordinator +from .designer.image_entity import ( + designer_extra_state_attributes, + designer_on_entity_added, +) PARALLEL_UPDATES = 0 @@ -20,7 +24,9 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the OpenDisplay image entity.""" - async_add_entities([OpenDisplayImageEntity(hass, entry.runtime_data.coordinator)]) + async_add_entities( + [OpenDisplayImageEntity(hass, entry.runtime_data.coordinator, entry)] + ) class OpenDisplayImageEntity(ImageEntity): @@ -30,10 +36,16 @@ class OpenDisplayImageEntity(ImageEntity): _attr_translation_key = "content" _attr_content_type = "image/jpeg" - def __init__(self, hass: HomeAssistant, coordinator: OpenDisplayCoordinator) -> None: + def __init__( + self, + hass: HomeAssistant, + coordinator: OpenDisplayCoordinator, + entry: OpenDisplayConfigEntry | None = None, + ) -> None: """Initialize the image entity.""" super().__init__(hass) self._coordinator = coordinator + self._designer_entry = entry self._attr_unique_id = f"{coordinator.address}-display_content" self._attr_device_info = DeviceInfo( connections={(CONNECTION_BLUETOOTH, coordinator.address)}, @@ -54,6 +66,7 @@ async def async_added_to_hass(self) -> None: self._handle_image_update, ) ) + await designer_on_entity_added(self, self._designer_entry) @callback def _handle_image_update(self, image_bytes: bytes) -> None: @@ -61,3 +74,8 @@ def _handle_image_update(self, image_bytes: bytes) -> None: self._image_bytes = image_bytes self._attr_image_last_updated = dt_util.utcnow() self.async_write_ha_state() + + @property + def extra_state_attributes(self) -> dict: + """Expose display capabilities for the designer.""" + return designer_extra_state_attributes(self, self._designer_entry) diff --git a/custom_components/opendisplay/manifest.json b/custom_components/opendisplay/manifest.json index bba8e08..36ebc74 100644 --- a/custom_components/opendisplay/manifest.json +++ b/custom_components/opendisplay/manifest.json @@ -9,7 +9,7 @@ ], "codeowners": ["@g4bri3lDev"], "config_flow": true, - "dependencies": ["bluetooth_adapters", "http", "recorder"], + "dependencies": ["bluetooth_adapters", "frontend", "http", "panel_custom", "recorder"], "documentation": "https://github.com/OpenDisplay/Home_Assistant_Integration", "integration_type": "device", "iot_class": "local_push",