feat: add device position undo history

Issue: #74
User-Visible: yes
This commit is contained in:
Matysh
2026-08-30 13:16:27 +00:00
committed by claude[bot]
parent 857537c620
commit bdf81fad58
55 changed files with 1091 additions and 281 deletions
@@ -1,125 +1,125 @@
{
"schema": 1,
"fingerprint": "fcf6976c5dddbb163f794a984a8e4b93955f5a1fe83373c94dd2abb9e5162e56",
"fingerprint": "aa9514085162d317577432073166a2385a0d511669f776f5268540be9317efef",
"entry": "houseplan-card.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-Dhz45m9k.js",
"houseplan-assets/houseplan-card-CaTFHDy3.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 280678,
"initialViewGzipBytes": 281796,
"lazyFiles": [
"houseplan-assets/backdrop-pick-CnZSQy0i.js",
"houseplan-assets/de-CQInYVzK.js",
"houseplan-assets/editor-DxZvDwp3.js",
"houseplan-assets/fr-BmVscop_.js",
"houseplan-assets/houseplan-editor-runtime-CbsKtXpC.js",
"houseplan-assets/houseplan-onboarding-runtime-jtdU6h_l.js"
"houseplan-assets/backdrop-pick-B6HYqsoo.js",
"houseplan-assets/de-CV45z-ti.js",
"houseplan-assets/editor-DFZF1CWt.js",
"houseplan-assets/fr-Ck0o4CMs.js",
"houseplan-assets/houseplan-editor-runtime-DrWOqPVN.js",
"houseplan-assets/houseplan-onboarding-runtime-wolz_s2E.js"
],
"lazyGzipBytes": 197394,
"lazyGzipBytes": 197638,
"lazyEditorFiles": [
"houseplan-assets/backdrop-pick-CnZSQy0i.js",
"houseplan-assets/editor-DxZvDwp3.js",
"houseplan-assets/houseplan-editor-runtime-CbsKtXpC.js"
"houseplan-assets/backdrop-pick-B6HYqsoo.js",
"houseplan-assets/editor-DFZF1CWt.js",
"houseplan-assets/houseplan-editor-runtime-DrWOqPVN.js"
],
"lazyEditorGzipBytes": 143764,
"lazyEditorGzipBytes": 143905,
"lazyOnboardingFiles": [
"houseplan-assets/backdrop-pick-CnZSQy0i.js",
"houseplan-assets/houseplan-onboarding-runtime-jtdU6h_l.js"
"houseplan-assets/backdrop-pick-B6HYqsoo.js",
"houseplan-assets/houseplan-onboarding-runtime-wolz_s2E.js"
],
"lazyOnboardingGzipBytes": 13790,
"lazyOnboardingGzipBytes": 13792,
"lazyLocaleFiles": [
"houseplan-assets/de-CQInYVzK.js",
"houseplan-assets/fr-BmVscop_.js"
"houseplan-assets/de-CV45z-ti.js",
"houseplan-assets/fr-Ck0o4CMs.js"
],
"lazyLocaleGzipBytes": 46912,
"lazyLocaleGzipBytes": 47013,
"files": [
{
"path": "houseplan-assets/backdrop-pick-CnZSQy0i.js",
"sha256": "88ef8c9dc5546644333250bd1c74880309cbad31cb7134d8a1fd80114288c865",
"path": "houseplan-assets/backdrop-pick-B6HYqsoo.js",
"sha256": "15bf3fd17239abfd64b336b191de40d1d4cc2cb812dc9e0f9f2c87caa845f03d",
"rawBytes": 20636,
"gzipBytes": 7072,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-Dhz45m9k.js"
"houseplan-assets/houseplan-card-CaTFHDy3.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/de-CQInYVzK.js",
"sha256": "ccf0bcc218d15199ec186ee04e4de4805eeb525e053d483887a0c146c231c8f8",
"rawBytes": 78176,
"gzipBytes": 23682,
"path": "houseplan-assets/de-CV45z-ti.js",
"sha256": "911c01b0d7e0a885f703403982923e4167acb8abc4da98a922cb9e6742240db0",
"rawBytes": 78310,
"gzipBytes": 23728,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-DxZvDwp3.js",
"sha256": "99a5e16cda136b8c02e62c87d8ed2912ad05cb8b3a4a344cf0bd6636ad5e9636",
"path": "houseplan-assets/editor-DFZF1CWt.js",
"sha256": "0f09c8de0f2538760376d145222d6c0dbcebb43163fec11259fa2d861ba51158",
"rawBytes": 3826,
"gzipBytes": 1581,
"gzipBytes": 1584,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-Dhz45m9k.js"
"houseplan-assets/houseplan-card-CaTFHDy3.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/fr-BmVscop_.js",
"sha256": "0c53056508a989f0db01ccd66ca809a93853a2a7477cb779434d1caaf6bfa2ed",
"rawBytes": 80344,
"gzipBytes": 23230,
"path": "houseplan-assets/fr-Ck0o4CMs.js",
"sha256": "bd7b7f20e4af5c371fc8165e6226fd2122c4734ce4cacb783765d40de2f59381",
"rawBytes": 80482,
"gzipBytes": 23285,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-Dhz45m9k.js",
"sha256": "17361b7baa37e7e989dc3caf8b6d412b829bc02b2724f84757abacf8ad4c5b7b",
"rawBytes": 991515,
"gzipBytes": 279883,
"path": "houseplan-assets/houseplan-card-CaTFHDy3.js",
"sha256": "5407c26bc8bd764f7d97e197cac43dc88dc1d4b44681f60532dbfbc0df98639f",
"rawBytes": 996305,
"gzipBytes": 280999,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/de-CQInYVzK.js",
"houseplan-assets/editor-DxZvDwp3.js",
"houseplan-assets/fr-BmVscop_.js",
"houseplan-assets/houseplan-editor-runtime-CbsKtXpC.js",
"houseplan-assets/houseplan-onboarding-runtime-jtdU6h_l.js"
"houseplan-assets/de-CV45z-ti.js",
"houseplan-assets/editor-DFZF1CWt.js",
"houseplan-assets/fr-Ck0o4CMs.js",
"houseplan-assets/houseplan-editor-runtime-DrWOqPVN.js",
"houseplan-assets/houseplan-onboarding-runtime-wolz_s2E.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-CbsKtXpC.js",
"sha256": "bdb3b6dcff0495ef15b59dbe69feef693b00733991b57555623c004e4793d0d9",
"rawBytes": 522799,
"gzipBytes": 135111,
"path": "houseplan-assets/houseplan-editor-runtime-DrWOqPVN.js",
"sha256": "a7f87e8bf473a5549841410f09e3105990e85d9fff948bb29824e6255a812212",
"rawBytes": 524026,
"gzipBytes": 135249,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-CnZSQy0i.js",
"houseplan-assets/houseplan-card-Dhz45m9k.js"
"houseplan-assets/backdrop-pick-B6HYqsoo.js",
"houseplan-assets/houseplan-card-CaTFHDy3.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-jtdU6h_l.js",
"sha256": "4933a98e4125964dd6ade0645b355857121855c7b2260fb472d91ef68c99c161",
"path": "houseplan-assets/houseplan-onboarding-runtime-wolz_s2E.js",
"sha256": "1bb9b33c9395fa9f2c85cb534e4e92354a0563efc45522e078ffe9da6fdc0c14",
"rawBytes": 27120,
"gzipBytes": 6718,
"gzipBytes": 6720,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-CnZSQy0i.js",
"houseplan-assets/houseplan-card-Dhz45m9k.js"
"houseplan-assets/backdrop-pick-B6HYqsoo.js",
"houseplan-assets/houseplan-card-CaTFHDy3.js"
],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "1fc4ad672293202be7cbc843c70c72fc009c5ff8be251189a4901ad6326bd1df",
"sha256": "c45de5b9d825097729b03b8ee0063ca7424236137b63a44bdec36aada307b0a0",
"rawBytes": 1183,
"gzipBytes": 795,
"gzipBytes": 797,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-Dhz45m9k.js"
"houseplan-assets/houseplan-card-CaTFHDy3.js"
],
"dynamicImports": []
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="fcf6976c5dddbb163f794a984a8e4b93955f5a1fe83373c94dd2abb9e5162e56";import{b as e,l as o,t,d7 as s,A as a,d8 as i,d9 as l,da as n,E as r,c}from"./houseplan-card-Dhz45m9k.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="aa9514085162d317577432073166a2385a0d511669f776f5268540be9317efef";import{b as e,l as o,t,d7 as s,A as a,d8 as i,d9 as l,da as n,E as r,c}from"./houseplan-card-CaTFHDy3.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
.hass=${this.hass}
.data=${this._formData}
.schema=${e}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="fcf6976c5dddbb163f794a984a8e4b93955f5a1fe83373c94dd2abb9e5162e56";import{l as t,ar as s,A as e,t as o,c as a,bV as l,bW as i,bX as h,bY as c,ao as n,bZ as p,c2 as r,b_ as _,b$ as d,c0 as u,c1 as g,cX as m,an as b,ap as $,cY as v}from"./houseplan-card-Dhz45m9k.js";import{i as f,c as y,e as w,r as D,a as S,b as k,s as C,t as M}from"./backdrop-pick-CnZSQy0i.js";const F=1e3,x=t=>{if(!t.trim())return null;const s=Number(t.replace(",","."));return Number.isFinite(s)?s:null},T="fcf6976c5dddbb163f794a984a8e4b93955f5a1fe83373c94dd2abb9e5162e56";class I{constructor(t){this.host=t,this._toggleServerPlans=async()=>{const t=this.host._spaceDialog;if(t)if(t.pickSaved)this.host._spaceDialog={...t,pickSaved:!1};else{this.host._spaceDialog={...t,pickSaved:!0,savedBusy:!0};try{const t=await this.host.hass.callWS({type:"houseplan/plans/list"}),s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:t?.plans||[],savedBusy:!1})}catch(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:[],savedBusy:!1}),this.host._showToast(this.host._t("toast.plans_list_failed",{err:this.host._errText(t)}))}}}}_help(l){const i=`${l}.aria`,h=t(this.host.hass,this.host._config?.language);return s(h,l)&&s(h,i)?a`<hp-help data-help-key=${l}
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="aa9514085162d317577432073166a2385a0d511669f776f5268540be9317efef";import{l as t,ar as s,A as e,t as o,c as a,bV as l,bW as i,bX as h,bY as c,ao as n,bZ as p,c2 as r,b_ as _,b$ as d,c0 as u,c1 as g,cX as m,an as b,ap as $,cY as v}from"./houseplan-card-CaTFHDy3.js";import{i as f,c as y,e as w,r as D,a as S,b as k,s as C,t as M}from"./backdrop-pick-B6HYqsoo.js";const F=1e3,x=t=>{if(!t.trim())return null;const s=Number(t.replace(",","."));return Number.isFinite(s)?s:null},T="aa9514085162d317577432073166a2385a0d511669f776f5268540be9317efef";class I{constructor(t){this.host=t,this._toggleServerPlans=async()=>{const t=this.host._spaceDialog;if(t)if(t.pickSaved)this.host._spaceDialog={...t,pickSaved:!1};else{this.host._spaceDialog={...t,pickSaved:!0,savedBusy:!0};try{const t=await this.host.hass.callWS({type:"houseplan/plans/list"}),s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:t?.plans||[],savedBusy:!1})}catch(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:[],savedBusy:!1}),this.host._showToast(this.host._t("toast.plans_list_failed",{err:this.host._errText(t)}))}}}}_help(l){const i=`${l}.aria`,h=t(this.host.hass,this.host._config?.language);return s(h,l)&&s(h,i)?a`<hp-help data-help-key=${l}
.text=${o(h,l)} .ariaLabel=${o(h,i)}></hp-help>`:e}_openSpaceDialog(t,s){if(!this.host._serverStorage||!this.host._serverCfg)return void this.host._showToast(this.host._t("toast.integration_missing"));if("edit"===t){const e=this.host._serverCfg.spaces.find(t=>t.id===s);if(!e)return;const o=l(e),a=e.settings?.custom_fill&&"object"==typeof e.settings.custom_fill?i(e.settings.custom_fill):null,r="none"===o.fill?{...a||h,a:0}:a;return void(this.host._spaceDialog={mode:t,spaceId:s,title:e.title,planUrl:e.plan_url||null,planFile:null,source:e.plan_url?"file":"draw",showBorders:o.showBorders,showNames:o.showNames,zeroWallStyle:p(e),displayTouched:!0,hideDecor:o.hideDecor,hideOpenings:o.hideOpenings,roomColor:o.color,roomOpacity:o.opacity,fillMode:"none"===o.fill?"custom":o.fill,customFill:r,glowEnabled:o.glow,bgColor:o.bgColor,bgMode:"static"===e.settings?.bg_mode||"daynight"===e.settings?.bg_mode?e.settings.bg_mode:null,northDeg:n({},e.settings),sunRays:"boolean"==typeof e.settings?.sun_rays?e.settings.sun_rays:null,tempMin:o.tempMin,tempMax:o.tempMax,showLqi:o.showLqi??this.host._config?.show_signal??!0,cardFontScale:o.cardFontScale,labelTemp:o.labelTemp,labelHum:o.labelHum,labelLqi:o.labelLqi,labelLight:o.labelLight,cellCm:Number(e.cell_cm)>0?Number(e.cell_cm):5,cellCmInput:c(Number(e.cell_cm)>0?Number(e.cell_cm):5,this.host._imperial),cellCmTouched:!1,busy:!1})}const e=r(this.host._imperial);this.host._spaceDialog={mode:t,title:"",planUrl:null,planFile:null,...f(),hideDecor:!1,hideOpenings:!1,zeroWallStyle:"dashed",roomColor:g,roomOpacity:u,fillMode:"custom",customFill:{...h,a:0},glowEnabled:!0,bgColor:null,bgMode:"daynight",northDeg:null,sunRays:null,tempMin:d,tempMax:_,showLqi:this.host._config?.show_signal??!0,cardFontScale:1,labelTemp:!1,labelHum:!1,labelLqi:!1,labelLight:!1,cellCm:e,cellCmInput:c(e,this.host._imperial),cellCmTouched:!1,busy:!1}}async _pickPlanFile(t){const s=t.target,e=s.files?.[0];if(!e||!this.host._spaceDialog)return;s.value="";const o=await y(e);if("reject"===o.kind)return void this.host._showToast(this.host._t("toast.plan_formats"));if("guard"===o.kind)return void(this.host._backdropGuard=o.state);const a=await w(e,o.ext,e.name);this.host._spaceDialog&&(this.host._spaceDialog={...this.host._spaceDialog,planFile:a})}_renderBackdropGuard(){return D(this.host,t=>{this.host._spaceDialog&&(this.host._spaceDialog={...this.host._spaceDialog,planFile:t})},()=>{this.host._backdropGuard=null},this.host.hass)??e}_useServerPlan(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,planUrl:t,planFile:null,pickSaved:!1,savedAspect:void 0},this.host._aspectJob=this._readPlanAspect(t))}async _readPlanAspect(t){for(let s=0;s<40;s++){const s=this.host._display(t);if(s){const e=await new Promise(t=>{const e=new Image;e.onload=()=>t(e.naturalWidth&&e.naturalHeight?e.naturalWidth/e.naturalHeight:0),e.onerror=()=>t(0),e.src=s}),o=this.host._spaceDialog;return o&&o.planUrl===t&&Number.isFinite(e)&&e>0?(this.host._spaceDialog={...o,savedAspect:e},e):0}if(await new Promise(t=>setTimeout(t,150)),this.host._spaceDialog?.planUrl!==t)return 0}return 0}async _deleteServerPlan(t){if(confirm(this.host._t("confirm.delete_plan",{name:t})))try{await this.host.hass.callWS({type:"houseplan/plans/delete",name:t});const s=this.host._spaceDialog;s?.saved&&(this.host._spaceDialog={...s,saved:s.saved.filter(s=>s.name!==t)})}catch(t){this.host._showToast(this.host._t("toast.plan_delete_failed",{err:this.host._errText(t)}))}}_renderServerPlans(t){if(t.savedBusy)return a`<div class="savedplans muted">${this.host._t("space.loading")}</div>`;const s=t.saved||[];if(!s.length)return a`<div class="savedplans muted">${this.host._t("space.no_saved")}</div>`;return a`<div class="savedplans">
${s.map(s=>{return a`
<div class="savedplan ${s.url===t.planUrl?"cur":""}">
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="fcf6976c5dddbb163f794a984a8e4b93955f5a1fe83373c94dd2abb9e5162e56";try{await import("./houseplan-assets/houseplan-card-Dhz45m9k.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="aa9514085162d317577432073166a2385a0d511669f776f5268540be9317efef";try{await import("./houseplan-assets/houseplan-card-CaTFHDy3.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
+316
View File
@@ -0,0 +1,316 @@
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const wait = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms));
const settle = async () => {
const started = performance.now();
do { await new Promise((resolve) => requestAnimationFrame(resolve)); }
while (c._modeTransitionBusy && performance.now() - started < 1500);
await c.updateComplete;
};
const idle = async () => {
const started = performance.now();
while (c._devicePositionBusy && performance.now() - started < 1500) await wait(10);
await c.updateComplete;
};
[...sr().querySelectorAll('.modetab')][1].click();
await settle();
const deviceNode = () => sr().querySelector('.dev:not(.ha-disabled)');
let node = deviceNode();
const deviceId = node?.getAttribute('data-id');
const device = c._devices.find((candidate) => candidate.id === deviceId);
if (!node || !device) return { fixtureReady: false };
out.fixtureReady = true;
const originalCallWs = c.hass.callWS;
let revision = 20;
let serverLayout = structuredClone(c._layout);
const writes = [];
let failNextUpdate = false;
let failNextDelete = false;
c.hass = { ...c.hass, callWS: async (message) => {
if (message.type === 'houseplan/layout/get') {
return { layout: structuredClone(serverLayout), rev: revision };
}
if (message.type === 'houseplan/layout/update') {
if (failNextUpdate) { failNextUpdate = false; throw new Error('update refused'); }
writes.push({ type: 'update', id: message.device_id, pos: structuredClone(message.pos) });
serverLayout = { ...serverLayout, [message.device_id]: structuredClone(message.pos) };
return { rev: ++revision };
}
if (message.type === 'houseplan/layout/delete') {
if (failNextDelete) { failNextDelete = false; throw new Error('delete refused'); }
writes.push({ type: 'delete', id: message.device_id });
delete serverLayout[message.device_id];
return { rev: ++revision };
}
return originalCallWs(message);
} };
c._serverStorage = true;
c._layoutRev = revision;
const fire = (target, type, x, y, pointerId = 74) => target.dispatchEvent(new PointerEvent(type, {
pointerId, pointerType: 'mouse', button: 0, buttons: type === 'pointerup' ? 0 : 1,
clientX: x, clientY: y, bubbles: true, composed: true, cancelable: true,
}));
const drag = async ({ dx = 80, cancel = false, cancelType = 'pointercancel', moves = 10, pointerId = 74 } = {}) => {
node = deviceNode();
const rect = node.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
fire(node, 'pointerdown', x, y, pointerId);
for (let index = 1; index <= moves; index++) {
fire(node, 'pointermove', x + dx * index / moves, y, pointerId);
}
const writesBeforeEnd = writes.length;
fire(node, cancel ? cancelType : 'pointerup', x + dx, y, pointerId);
await idle();
return writesBeforeEnd;
};
const beginPreview = (dx, pointerId) => {
node = deviceNode();
const rect = node.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
fire(node, 'pointerdown', x, y, pointerId);
fire(node, 'pointermove', x + dx, y, pointerId);
return { node, x, y };
};
const explicit = c._devicePlacementForCanvas(device, c._pos(device).x, c._pos(device).y);
c._layout = { ...c._layout, [deviceId]: { ...explicit, k: 0, future: 'kept' } };
serverLayout = structuredClone(c._layout);
c._layoutContentFingerprint = '';
c._devicePositionHistory.clear();
c.requestUpdate();
await c.updateComplete;
const writesBeforeRelease = await drag();
out.previewDoesNotPersist = writesBeforeRelease === 0;
out.oneDragOneWrite = writes.length === 1 && writes[0].type === 'update';
out.oneDragOneCommand = c._devicePositionHistory.size === 1;
out.futureFieldsSurviveDrag = c._layout[deviceId].k === 0
&& c._layout[deviceId].future === 'kept';
out.undoEnabled = !sr().querySelector('[data-device-position-history="undo"]').disabled;
const afterDrag = structuredClone(c._layout[deviceId]);
sr().querySelector('[data-device-position-history="undo"]').click();
await idle();
out.undoRestoresExactStart = c._layout[deviceId].x === explicit.x
&& c._layout[deviceId].y === explicit.y
&& c._layout[deviceId].k === 0
&& c._devicePositionHistory.canRedo;
sr().querySelector('[data-device-position-history="redo"]').click();
await idle();
out.redoRestoresExactEnd = JSON.stringify(c._layout[deviceId]) === JSON.stringify(afterDrag);
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'z', code: 'KeyZ', ctrlKey: true, bubbles: true, composed: true, cancelable: true,
}));
await idle();
out.keyboardUndoWorks = c._devicePositionHistory.canRedo
&& c._layout[deviceId].x === explicit.x && c._layout[deviceId].y === explicit.y;
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'z', code: 'KeyZ', ctrlKey: true, shiftKey: true,
bubbles: true, composed: true, cancelable: true,
}));
await idle();
out.keyboardRedoWorks = JSON.stringify(c._layout[deviceId]) === JSON.stringify(afterDrag);
// Escape and an Undo request during preview abort only the uncommitted drag.
const beforeEscape = structuredClone(c._layout[deviceId]);
const writesBeforeEscape = writes.length;
beginPreview(95, 741);
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Escape', code: 'Escape', bubbles: true, composed: true, cancelable: true,
}));
await c.updateComplete;
out.escapeAbortsWithoutWrite = JSON.stringify(c._layout[deviceId]) === JSON.stringify(beforeEscape)
&& writes.length === writesBeforeEscape && c._deviceDrag === null;
const historySizeBeforePreviewUndo = c._devicePositionHistory.size;
beginPreview(105, 742);
sr().querySelector('[data-device-position-history="undo"]').click();
await c.updateComplete;
out.undoDuringDragOnlyAborts = JSON.stringify(c._layout[deviceId]) === JSON.stringify(beforeEscape)
&& writes.length === writesBeforeEscape
&& c._devicePositionHistory.size === historySizeBeforePreviewUndo
&& c._deviceDrag === null;
// Leaving the editor is another abort boundary; re-enter for the remaining cases.
beginPreview(115, 743);
[...sr().querySelectorAll('.modetab')][0].click();
await settle();
out.modeSwitchAbortsWithoutWrite = JSON.stringify(c._layout[deviceId]) === JSON.stringify(beforeEscape)
&& writes.length === writesBeforeEscape && c._deviceDrag === null;
[...sr().querySelectorAll('.modetab')][1].click();
await settle();
// A no-op after Undo must not destroy the redo branch.
sr().querySelector('[data-device-position-history="undo"]').click();
await idle();
const redoName = c._devicePositionHistory.redoName;
const writesBeforeNoop = writes.length;
await drag({ dx: 0, moves: 0, pointerId: 75 });
out.noopKeepsRedo = c._devicePositionHistory.redoName === redoName
&& writes.length === writesBeforeNoop;
sr().querySelector('[data-device-position-history="redo"]').click();
await idle();
// Cancellation restores the raw before-state and performs no final write.
const beforeCancel = structuredClone(c._layout[deviceId]);
const writesBeforeCancel = writes.length;
await drag({ dx: 120, cancel: true, pointerId: 76 });
out.cancelRestoresWithoutWrite = JSON.stringify(c._layout[deviceId]) === JSON.stringify(beforeCancel)
&& writes.length === writesBeforeCancel;
await drag({ dx: 120, cancel: true, cancelType: 'lostpointercapture', pointerId: 761 });
out.lostCaptureRestoresWithoutWrite = JSON.stringify(c._layout[deviceId]) === JSON.stringify(beforeCancel)
&& writes.length === writesBeforeCancel;
// A second pointer invalidates the first single-pointer transaction.
node = deviceNode();
let rect = node.getBoundingClientRect();
let x = rect.left + rect.width / 2;
let y = rect.top + rect.height / 2;
fire(node, 'pointerdown', x, y, 762);
fire(node, 'pointermove', x + 100, y, 762);
fire(node, 'pointerdown', x, y, 763);
await c.updateComplete;
out.secondPointerAbortsWithoutWrite = JSON.stringify(c._layout[deviceId]) === JSON.stringify(beforeCancel)
&& writes.length === writesBeforeCancel && c._deviceDrag === null;
// An auto-positioned marker round-trips through delete/update.
c._devicePositionHistory.clear();
delete c._layout[deviceId];
delete serverLayout[deviceId];
c.requestUpdate();
await c.updateComplete;
await drag({ dx: 90, pointerId: 77 });
const autoAfter = structuredClone(c._layout[deviceId]);
const beforeAutoUndo = writes.length;
sr().querySelector('[data-device-position-history="undo"]').click();
await idle();
out.autoUndoDeletesExplicitPlacement = !(deviceId in c._layout)
&& writes[beforeAutoUndo]?.type === 'delete';
sr().querySelector('[data-device-position-history="redo"]').click();
await idle();
out.autoRedoRestoresPlacement = JSON.stringify(c._layout[deviceId]) === JSON.stringify(autoAfter)
&& writes.at(-1)?.type === 'update';
// Failed final update never enters history; failed Undo restores stack direction.
c._devicePositionHistory.clear();
const beforeFailedDrag = structuredClone(c._layout[deviceId]);
failNextUpdate = true;
await drag({ dx: 110, pointerId: 78 });
out.failedDragRollsBack = JSON.stringify(c._layout[deviceId]) === JSON.stringify(beforeFailedDrag)
&& !c._devicePositionHistory.canUndo;
delete c._layout[deviceId];
delete serverLayout[deviceId];
c.requestUpdate();
await c.updateComplete;
await drag({ dx: 100, pointerId: 79 });
const beforeFailedDelete = structuredClone(c._layout[deviceId]);
failNextDelete = true;
sr().querySelector('[data-device-position-history="undo"]').click();
await idle();
out.failedUndoRestoresStackDirection = JSON.stringify(c._layout[deviceId])
=== JSON.stringify(beforeFailedDelete)
&& c._devicePositionHistory.canUndo && !c._devicePositionHistory.canRedo;
// Native text history stays native even with an available command.
const input = document.createElement('input');
sr().append(input);
const sizeBeforeInput = c._devicePositionHistory.size;
input.dispatchEvent(new KeyboardEvent('keydown', {
key: 'z', code: 'KeyZ', ctrlKey: true, bubbles: true, composed: true, cancelable: true,
}));
await wait();
out.nativeInputHistoryNotIntercepted = c._devicePositionHistory.size === sizeBeforeInput;
input.remove();
// Same-content reload is an own/reconnect echo; different content is remote authority.
serverLayout = structuredClone(c._layout);
await c._reloadLayoutOnly();
out.sameContentReloadKeepsHistory = c._devicePositionHistory.canUndo;
serverLayout = {
...serverLayout,
[deviceId]: { ...serverLayout[deviceId], x: serverLayout[deviceId].x + 0.01 },
};
await c._reloadLayoutOnly();
out.remoteContentClearsHistory = !c._devicePositionHistory.canUndo
&& !c._devicePositionHistory.canRedo;
// A valid command owns its original space and makes the result visible there.
const otherDevice = c._devices.find((candidate) => candidate.space !== device.space
&& candidate.bindingStatus?.kind !== 'ha_disabled');
if (otherDevice) {
const otherBefore = { s: otherDevice.space, x: 0.55, y: 0.55 };
const otherAfter = { s: otherDevice.space, x: 0.6, y: 0.55 };
c._layout = { ...c._layout, [otherDevice.id]: structuredClone(otherAfter) };
serverLayout = structuredClone(c._layout);
c._layoutContentFingerprint = '';
c._devicePositionHistory.push({
name: 'other-space move',
before: { deviceId: otherDevice.id, spaceId: otherDevice.space, placement: otherBefore },
after: { deviceId: otherDevice.id, spaceId: otherDevice.space, placement: otherAfter },
});
c.requestUpdate();
await c.updateComplete;
sr().querySelector('[data-device-position-history="undo"]').click();
await idle();
await settle();
const observedOther = c._layout[otherDevice.id];
out.otherSpaceUndoIsVisible = c._space === otherDevice.space
&& observedOther?.s === otherBefore.s
&& observedOther?.x === otherBefore.x
&& observedOther?.y === otherBefore.y;
c._commitSpace(device.space);
await settle();
} else {
out.otherSpaceUndoIsVisible = false;
}
// Stale commands fail closed instead of recreating a deleted/rebound/disabled marker.
const staleCommand = (id, spaceId) => ({
name: 'stale move',
before: { deviceId: id, spaceId, placement: { s: spaceId, x: 0.4, y: 0.4 } },
after: { deviceId: id, spaceId, placement: { s: spaceId, x: 0.45, y: 0.4 } },
});
const writesBeforeStale = writes.length;
c._devicePositionHistory.clear();
c._devicePositionHistory.push(staleCommand('removed-device', device.space));
c.requestUpdate();
await c.updateComplete;
sr().querySelector('[data-device-position-history="undo"]').click();
await c.updateComplete;
out.deletedDeviceCommandFailsClosed = !c._devicePositionHistory.canUndo
&& !c._devicePositionHistory.canRedo && writes.length === writesBeforeStale;
c._devicePositionHistory.push(staleCommand(device.id, 'garden'));
c.requestUpdate();
await c.updateComplete;
sr().querySelector('[data-device-position-history="undo"]').click();
await c.updateComplete;
out.reboundDeviceCommandFailsClosed = !c._devicePositionHistory.canUndo
&& !c._devicePositionHistory.canRedo && writes.length === writesBeforeStale;
const devicesBeforeDisable = c._devices;
c._devices = devicesBeforeDisable.map((candidate) => candidate.id === device.id
? { ...candidate, bindingStatus: { kind: 'ha_disabled' } } : candidate);
c._devicePositionHistory.push(staleCommand(device.id, device.space));
c.requestUpdate();
await c.updateComplete;
sr().querySelector('[data-device-position-history="undo"]').click();
await c.updateComplete;
out.disabledDeviceCommandFailsClosed = !c._devicePositionHistory.canUndo
&& !c._devicePositionHistory.canRedo && writes.length === writesBeforeStale;
c._devices = devicesBeforeDisable;
return out;
});
checkAll(res, {});
await finish(browser, res);
+5 -1
View File
@@ -158,7 +158,11 @@ const res = await page.evaluate(async () => {
out.devBar = !!sr().querySelector('.editbar.devbar');
const deviceToolbarButtons = [...sr().querySelectorAll('.editbar.devbar .btn:not(.barclose)')];
const addDeviceButton = deviceToolbarButtons[0];
out.devBarBtns = deviceToolbarButtons.length === 3;
out.devBarBtns = deviceToolbarButtons.length === 5;
out.deviceHistoryControlsArePersistent = !!sr().querySelector('[data-device-position-history="undo"]')
&& !!sr().querySelector('[data-device-position-history="redo"]')
&& sr().querySelector('[data-device-position-history="undo"]').disabled
&& sr().querySelector('[data-device-position-history="redo"]').disabled;
out.addDeviceShortcutUsesNativeKeyboardSemantics = addDeviceButton?.tagName === 'BUTTON'
&& addDeviceButton.disabled === false;
out.addDeviceShortcut = addDeviceButton?.textContent.trim() === c._t('devbar.add')
+16 -3
View File
@@ -71,9 +71,22 @@ const out = await page.evaluate(async () => {
bubbles: true, ...extra,
});
};
c._drag = { id: dev.id, sx: 400, sy: 400, ox: p0.x, oy: p0.y, moved: false };
c._pointerMove(mkDelta(137.7 - p0.x + OFF, 251.3 - p0.y + OFF), dev);
c._drag = null;
const devNode = sr().querySelector(`.dev[data-id="${dev.id}"]`);
const devRect = devNode.getBoundingClientRect();
const stageRect = sr().querySelector('.stage').getBoundingClientRect();
const deviceView = c._viewOr(c._baseVb());
const startX = devRect.left + devRect.width / 2;
const startY = devRect.top + devRect.height / 2;
const dxPx = ((137.7 - p0.x + OFF) / deviceView.w) * stageRect.width;
const dyPx = ((251.3 - p0.y + OFF) / deviceView.h) * stageRect.height;
const devicePointer = (type, x, y, buttons) => devNode.dispatchEvent(new PointerEvent(type, {
pointerId: 174, pointerType: 'mouse', button: 0, buttons,
clientX: x, clientY: y, bubbles: true, composed: true, cancelable: true,
}));
devicePointer('pointerdown', startX, startY, 1);
devicePointer('pointermove', startX + dxPx, startY + dyPx, 1);
devicePointer('pointerup', startX + dxPx, startY + dyPx, 0);
while (c._devicePositionBusy) await new Promise((resolve) => setTimeout(resolve, 5));
o.deviceLandsOnANode = onGridN(c._layout[dev.id].x) && onGridN(c._layout[dev.id].y);
// ---- 1c) a ROOM LABEL too ---------------------------------------------
+4 -4
View File
@@ -141,17 +141,17 @@ Object.assign(out, await page.evaluate(async () => {
const v0 = { ...c._viewOr(c._baseVb()) };
const r = dev.getBoundingClientRect();
fire(dev, 'pointerdown', 22, r.left + r.width / 2, r.top + r.height / 2);
o.deviceTakesThePointer = !!c._drag && c._panStart === null;
o.deviceTakesThePointer = !!c._deviceDrag && c._panStart === null;
fire(dev, 'pointermove', 22, r.left + r.width / 2 + 40, r.top + r.height / 2 + 30);
c._stagePointerMove({ pointerId: 22, clientX: r.left + 60, clientY: r.top + 50 });
const v1 = { ...c._viewOr(c._baseVb()) };
o.deviceDragDoesNotPan = Math.abs(v1.x - v0.x) < 0.5 && Math.abs(v1.y - v0.y) < 0.5;
o.deviceActuallyMoved = !!c._drag && c._drag.moved === true;
o.deviceActuallyMoved = !!c._deviceDrag && c._deviceDrag.moved === true;
fire(dev, 'pointerup', 22, r.left + r.width / 2 + 40, r.top + r.height / 2 + 30);
// _pointerUp releases _drag on the next macrotask, and _stagePointerDown
// _pointerUp releases the device drag before its serial final write, and _stagePointerDown
// refuses to start a gesture while a device drag is live
await new Promise((r2) => setTimeout(r2, 20));
o.deviceDragReleased = c._drag === null;
o.deviceDragReleased = c._deviceDrag === null;
await c.updateComplete;
}
+58 -58
View File
@@ -1,125 +1,125 @@
{
"schema": 1,
"fingerprint": "fcf6976c5dddbb163f794a984a8e4b93955f5a1fe83373c94dd2abb9e5162e56",
"fingerprint": "aa9514085162d317577432073166a2385a0d511669f776f5268540be9317efef",
"entry": "houseplan-card.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-Dhz45m9k.js",
"houseplan-assets/houseplan-card-CaTFHDy3.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 280678,
"initialViewGzipBytes": 281796,
"lazyFiles": [
"houseplan-assets/backdrop-pick-CnZSQy0i.js",
"houseplan-assets/de-CQInYVzK.js",
"houseplan-assets/editor-DxZvDwp3.js",
"houseplan-assets/fr-BmVscop_.js",
"houseplan-assets/houseplan-editor-runtime-CbsKtXpC.js",
"houseplan-assets/houseplan-onboarding-runtime-jtdU6h_l.js"
"houseplan-assets/backdrop-pick-B6HYqsoo.js",
"houseplan-assets/de-CV45z-ti.js",
"houseplan-assets/editor-DFZF1CWt.js",
"houseplan-assets/fr-Ck0o4CMs.js",
"houseplan-assets/houseplan-editor-runtime-DrWOqPVN.js",
"houseplan-assets/houseplan-onboarding-runtime-wolz_s2E.js"
],
"lazyGzipBytes": 197394,
"lazyGzipBytes": 197638,
"lazyEditorFiles": [
"houseplan-assets/backdrop-pick-CnZSQy0i.js",
"houseplan-assets/editor-DxZvDwp3.js",
"houseplan-assets/houseplan-editor-runtime-CbsKtXpC.js"
"houseplan-assets/backdrop-pick-B6HYqsoo.js",
"houseplan-assets/editor-DFZF1CWt.js",
"houseplan-assets/houseplan-editor-runtime-DrWOqPVN.js"
],
"lazyEditorGzipBytes": 143764,
"lazyEditorGzipBytes": 143905,
"lazyOnboardingFiles": [
"houseplan-assets/backdrop-pick-CnZSQy0i.js",
"houseplan-assets/houseplan-onboarding-runtime-jtdU6h_l.js"
"houseplan-assets/backdrop-pick-B6HYqsoo.js",
"houseplan-assets/houseplan-onboarding-runtime-wolz_s2E.js"
],
"lazyOnboardingGzipBytes": 13790,
"lazyOnboardingGzipBytes": 13792,
"lazyLocaleFiles": [
"houseplan-assets/de-CQInYVzK.js",
"houseplan-assets/fr-BmVscop_.js"
"houseplan-assets/de-CV45z-ti.js",
"houseplan-assets/fr-Ck0o4CMs.js"
],
"lazyLocaleGzipBytes": 46912,
"lazyLocaleGzipBytes": 47013,
"files": [
{
"path": "houseplan-assets/backdrop-pick-CnZSQy0i.js",
"sha256": "88ef8c9dc5546644333250bd1c74880309cbad31cb7134d8a1fd80114288c865",
"path": "houseplan-assets/backdrop-pick-B6HYqsoo.js",
"sha256": "15bf3fd17239abfd64b336b191de40d1d4cc2cb812dc9e0f9f2c87caa845f03d",
"rawBytes": 20636,
"gzipBytes": 7072,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-Dhz45m9k.js"
"houseplan-assets/houseplan-card-CaTFHDy3.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/de-CQInYVzK.js",
"sha256": "ccf0bcc218d15199ec186ee04e4de4805eeb525e053d483887a0c146c231c8f8",
"rawBytes": 78176,
"gzipBytes": 23682,
"path": "houseplan-assets/de-CV45z-ti.js",
"sha256": "911c01b0d7e0a885f703403982923e4167acb8abc4da98a922cb9e6742240db0",
"rawBytes": 78310,
"gzipBytes": 23728,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-DxZvDwp3.js",
"sha256": "99a5e16cda136b8c02e62c87d8ed2912ad05cb8b3a4a344cf0bd6636ad5e9636",
"path": "houseplan-assets/editor-DFZF1CWt.js",
"sha256": "0f09c8de0f2538760376d145222d6c0dbcebb43163fec11259fa2d861ba51158",
"rawBytes": 3826,
"gzipBytes": 1581,
"gzipBytes": 1584,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-Dhz45m9k.js"
"houseplan-assets/houseplan-card-CaTFHDy3.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/fr-BmVscop_.js",
"sha256": "0c53056508a989f0db01ccd66ca809a93853a2a7477cb779434d1caaf6bfa2ed",
"rawBytes": 80344,
"gzipBytes": 23230,
"path": "houseplan-assets/fr-Ck0o4CMs.js",
"sha256": "bd7b7f20e4af5c371fc8165e6226fd2122c4734ce4cacb783765d40de2f59381",
"rawBytes": 80482,
"gzipBytes": 23285,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-Dhz45m9k.js",
"sha256": "17361b7baa37e7e989dc3caf8b6d412b829bc02b2724f84757abacf8ad4c5b7b",
"rawBytes": 991515,
"gzipBytes": 279883,
"path": "houseplan-assets/houseplan-card-CaTFHDy3.js",
"sha256": "5407c26bc8bd764f7d97e197cac43dc88dc1d4b44681f60532dbfbc0df98639f",
"rawBytes": 996305,
"gzipBytes": 280999,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/de-CQInYVzK.js",
"houseplan-assets/editor-DxZvDwp3.js",
"houseplan-assets/fr-BmVscop_.js",
"houseplan-assets/houseplan-editor-runtime-CbsKtXpC.js",
"houseplan-assets/houseplan-onboarding-runtime-jtdU6h_l.js"
"houseplan-assets/de-CV45z-ti.js",
"houseplan-assets/editor-DFZF1CWt.js",
"houseplan-assets/fr-Ck0o4CMs.js",
"houseplan-assets/houseplan-editor-runtime-DrWOqPVN.js",
"houseplan-assets/houseplan-onboarding-runtime-wolz_s2E.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-CbsKtXpC.js",
"sha256": "bdb3b6dcff0495ef15b59dbe69feef693b00733991b57555623c004e4793d0d9",
"rawBytes": 522799,
"gzipBytes": 135111,
"path": "houseplan-assets/houseplan-editor-runtime-DrWOqPVN.js",
"sha256": "a7f87e8bf473a5549841410f09e3105990e85d9fff948bb29824e6255a812212",
"rawBytes": 524026,
"gzipBytes": 135249,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-CnZSQy0i.js",
"houseplan-assets/houseplan-card-Dhz45m9k.js"
"houseplan-assets/backdrop-pick-B6HYqsoo.js",
"houseplan-assets/houseplan-card-CaTFHDy3.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-jtdU6h_l.js",
"sha256": "4933a98e4125964dd6ade0645b355857121855c7b2260fb472d91ef68c99c161",
"path": "houseplan-assets/houseplan-onboarding-runtime-wolz_s2E.js",
"sha256": "1bb9b33c9395fa9f2c85cb534e4e92354a0563efc45522e078ffe9da6fdc0c14",
"rawBytes": 27120,
"gzipBytes": 6718,
"gzipBytes": 6720,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-CnZSQy0i.js",
"houseplan-assets/houseplan-card-Dhz45m9k.js"
"houseplan-assets/backdrop-pick-B6HYqsoo.js",
"houseplan-assets/houseplan-card-CaTFHDy3.js"
],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "1fc4ad672293202be7cbc843c70c72fc009c5ff8be251189a4901ad6326bd1df",
"sha256": "c45de5b9d825097729b03b8ee0063ca7424236137b63a44bdec36aada307b0a0",
"rawBytes": 1183,
"gzipBytes": 795,
"gzipBytes": 797,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-Dhz45m9k.js"
"houseplan-assets/houseplan-card-CaTFHDy3.js"
],
"dynamicImports": []
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="fcf6976c5dddbb163f794a984a8e4b93955f5a1fe83373c94dd2abb9e5162e56";import{b as e,l as o,t,d7 as s,A as a,d8 as i,d9 as l,da as n,E as r,c}from"./houseplan-card-Dhz45m9k.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="aa9514085162d317577432073166a2385a0d511669f776f5268540be9317efef";import{b as e,l as o,t,d7 as s,A as a,d8 as i,d9 as l,da as n,E as r,c}from"./houseplan-card-CaTFHDy3.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
.hass=${this.hass}
.data=${this._formData}
.schema=${e}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="fcf6976c5dddbb163f794a984a8e4b93955f5a1fe83373c94dd2abb9e5162e56";import{l as t,ar as s,A as e,t as o,c as a,bV as l,bW as i,bX as h,bY as c,ao as n,bZ as p,c2 as r,b_ as _,b$ as d,c0 as u,c1 as g,cX as m,an as b,ap as $,cY as v}from"./houseplan-card-Dhz45m9k.js";import{i as f,c as y,e as w,r as D,a as S,b as k,s as C,t as M}from"./backdrop-pick-CnZSQy0i.js";const F=1e3,x=t=>{if(!t.trim())return null;const s=Number(t.replace(",","."));return Number.isFinite(s)?s:null},T="fcf6976c5dddbb163f794a984a8e4b93955f5a1fe83373c94dd2abb9e5162e56";class I{constructor(t){this.host=t,this._toggleServerPlans=async()=>{const t=this.host._spaceDialog;if(t)if(t.pickSaved)this.host._spaceDialog={...t,pickSaved:!1};else{this.host._spaceDialog={...t,pickSaved:!0,savedBusy:!0};try{const t=await this.host.hass.callWS({type:"houseplan/plans/list"}),s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:t?.plans||[],savedBusy:!1})}catch(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:[],savedBusy:!1}),this.host._showToast(this.host._t("toast.plans_list_failed",{err:this.host._errText(t)}))}}}}_help(l){const i=`${l}.aria`,h=t(this.host.hass,this.host._config?.language);return s(h,l)&&s(h,i)?a`<hp-help data-help-key=${l}
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="aa9514085162d317577432073166a2385a0d511669f776f5268540be9317efef";import{l as t,ar as s,A as e,t as o,c as a,bV as l,bW as i,bX as h,bY as c,ao as n,bZ as p,c2 as r,b_ as _,b$ as d,c0 as u,c1 as g,cX as m,an as b,ap as $,cY as v}from"./houseplan-card-CaTFHDy3.js";import{i as f,c as y,e as w,r as D,a as S,b as k,s as C,t as M}from"./backdrop-pick-B6HYqsoo.js";const F=1e3,x=t=>{if(!t.trim())return null;const s=Number(t.replace(",","."));return Number.isFinite(s)?s:null},T="aa9514085162d317577432073166a2385a0d511669f776f5268540be9317efef";class I{constructor(t){this.host=t,this._toggleServerPlans=async()=>{const t=this.host._spaceDialog;if(t)if(t.pickSaved)this.host._spaceDialog={...t,pickSaved:!1};else{this.host._spaceDialog={...t,pickSaved:!0,savedBusy:!0};try{const t=await this.host.hass.callWS({type:"houseplan/plans/list"}),s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:t?.plans||[],savedBusy:!1})}catch(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:[],savedBusy:!1}),this.host._showToast(this.host._t("toast.plans_list_failed",{err:this.host._errText(t)}))}}}}_help(l){const i=`${l}.aria`,h=t(this.host.hass,this.host._config?.language);return s(h,l)&&s(h,i)?a`<hp-help data-help-key=${l}
.text=${o(h,l)} .ariaLabel=${o(h,i)}></hp-help>`:e}_openSpaceDialog(t,s){if(!this.host._serverStorage||!this.host._serverCfg)return void this.host._showToast(this.host._t("toast.integration_missing"));if("edit"===t){const e=this.host._serverCfg.spaces.find(t=>t.id===s);if(!e)return;const o=l(e),a=e.settings?.custom_fill&&"object"==typeof e.settings.custom_fill?i(e.settings.custom_fill):null,r="none"===o.fill?{...a||h,a:0}:a;return void(this.host._spaceDialog={mode:t,spaceId:s,title:e.title,planUrl:e.plan_url||null,planFile:null,source:e.plan_url?"file":"draw",showBorders:o.showBorders,showNames:o.showNames,zeroWallStyle:p(e),displayTouched:!0,hideDecor:o.hideDecor,hideOpenings:o.hideOpenings,roomColor:o.color,roomOpacity:o.opacity,fillMode:"none"===o.fill?"custom":o.fill,customFill:r,glowEnabled:o.glow,bgColor:o.bgColor,bgMode:"static"===e.settings?.bg_mode||"daynight"===e.settings?.bg_mode?e.settings.bg_mode:null,northDeg:n({},e.settings),sunRays:"boolean"==typeof e.settings?.sun_rays?e.settings.sun_rays:null,tempMin:o.tempMin,tempMax:o.tempMax,showLqi:o.showLqi??this.host._config?.show_signal??!0,cardFontScale:o.cardFontScale,labelTemp:o.labelTemp,labelHum:o.labelHum,labelLqi:o.labelLqi,labelLight:o.labelLight,cellCm:Number(e.cell_cm)>0?Number(e.cell_cm):5,cellCmInput:c(Number(e.cell_cm)>0?Number(e.cell_cm):5,this.host._imperial),cellCmTouched:!1,busy:!1})}const e=r(this.host._imperial);this.host._spaceDialog={mode:t,title:"",planUrl:null,planFile:null,...f(),hideDecor:!1,hideOpenings:!1,zeroWallStyle:"dashed",roomColor:g,roomOpacity:u,fillMode:"custom",customFill:{...h,a:0},glowEnabled:!0,bgColor:null,bgMode:"daynight",northDeg:null,sunRays:null,tempMin:d,tempMax:_,showLqi:this.host._config?.show_signal??!0,cardFontScale:1,labelTemp:!1,labelHum:!1,labelLqi:!1,labelLight:!1,cellCm:e,cellCmInput:c(e,this.host._imperial),cellCmTouched:!1,busy:!1}}async _pickPlanFile(t){const s=t.target,e=s.files?.[0];if(!e||!this.host._spaceDialog)return;s.value="";const o=await y(e);if("reject"===o.kind)return void this.host._showToast(this.host._t("toast.plan_formats"));if("guard"===o.kind)return void(this.host._backdropGuard=o.state);const a=await w(e,o.ext,e.name);this.host._spaceDialog&&(this.host._spaceDialog={...this.host._spaceDialog,planFile:a})}_renderBackdropGuard(){return D(this.host,t=>{this.host._spaceDialog&&(this.host._spaceDialog={...this.host._spaceDialog,planFile:t})},()=>{this.host._backdropGuard=null},this.host.hass)??e}_useServerPlan(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,planUrl:t,planFile:null,pickSaved:!1,savedAspect:void 0},this.host._aspectJob=this._readPlanAspect(t))}async _readPlanAspect(t){for(let s=0;s<40;s++){const s=this.host._display(t);if(s){const e=await new Promise(t=>{const e=new Image;e.onload=()=>t(e.naturalWidth&&e.naturalHeight?e.naturalWidth/e.naturalHeight:0),e.onerror=()=>t(0),e.src=s}),o=this.host._spaceDialog;return o&&o.planUrl===t&&Number.isFinite(e)&&e>0?(this.host._spaceDialog={...o,savedAspect:e},e):0}if(await new Promise(t=>setTimeout(t,150)),this.host._spaceDialog?.planUrl!==t)return 0}return 0}async _deleteServerPlan(t){if(confirm(this.host._t("confirm.delete_plan",{name:t})))try{await this.host.hass.callWS({type:"houseplan/plans/delete",name:t});const s=this.host._spaceDialog;s?.saved&&(this.host._spaceDialog={...s,saved:s.saved.filter(s=>s.name!==t)})}catch(t){this.host._showToast(this.host._t("toast.plan_delete_failed",{err:this.host._errText(t)}))}}_renderServerPlans(t){if(t.savedBusy)return a`<div class="savedplans muted">${this.host._t("space.loading")}</div>`;const s=t.saved||[];if(!s.length)return a`<div class="savedplans muted">${this.host._t("space.no_saved")}</div>`;return a`<div class="savedplans">
${s.map(s=>{return a`
<div class="savedplan ${s.url===t.planUrl?"cur":""}">
+1 -1
View File
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="fcf6976c5dddbb163f794a984a8e4b93955f5a1fe83373c94dd2abb9e5162e56";try{await import("./houseplan-assets/houseplan-card-Dhz45m9k.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="aa9514085162d317577432073166a2385a0d511669f776f5268540be9317efef";try{await import("./houseplan-assets/houseplan-card-CaTFHDy3.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
+6
View File
@@ -2,6 +2,12 @@
## Unreleased
- The Device editor now has its own Undo/Redo history for marker positions.
One completed drag is one step; cancelled or failed moves add nothing, and
the 50-step session history is available from persistent toolbar buttons or
`Ctrl/Cmd+Z`, `Ctrl/Cmd+Shift+Z` and `Ctrl+Y`
([#74](https://github.com/Matysh/houseplan-card/issues/74)).
- Device discovery lost its last hidden knobs: light grouping and the
excluded-integrations list are now visible **Discovery filters** on the
catalog's Available tab, with appear/disappear counters before saving and
+6
View File
@@ -8,6 +8,12 @@
## Не выпущено
- В редакторе устройств появилась отдельная история отмены и повтора позиций
маркеров. Одно завершённое перетаскивание — один шаг; отменённые и неудачные
перемещения в историю не попадают. До 50 шагов текущей сессии доступны через
постоянные кнопки панели или `Ctrl/Cmd+Z`, `Ctrl/Cmd+Shift+Z` и `Ctrl+Y`
([#74](https://github.com/Matysh/houseplan-card/issues/74)).
- У обнаружения устройств не осталось скрытых ручек: группировка света и
список исключённых интеграций стали видимыми «Фильтрами обнаружения» на
вкладке «Доступны» каталога — со счётчиками «появится/скроется» до записи
+14 -2
View File
@@ -1735,8 +1735,20 @@ separately promised workflows:
icon preview; opening/saving untouched must keep the explicit override
empty, and the hint disappears once an explicit icon is picked
[auto: smoke_icon_placeholder]
- [ ] No Reset button (v1.33.2): the Device editor toolbar has three tools —
add, show all, icon rules; the layout-wiping Reset is gone [auto: smoke_editor_tabs]
- [ ] Device-position history (#74): the Device editor always shows Undo/Redo
before Close. One pointerup after a real snapped move creates exactly one
point-wise server write and one command; Undo/Redo use update or delete
as appropriate and preserve `s`, `k: 0` and unknown sibling fields.
Pointercancel, lost capture, a second pointer, Escape, no movement and a
failed write restore the preview and create no command. A failed history
write restores the previous stack direction; own storage echoes preserve
history while a different remote baseline, rebinding, deletion or
optimization clears it. The independent stack is limited to 50 moves;
`Ctrl/Cmd+Z`, `Ctrl/Cmd+Shift+Z` and `Ctrl+Y` work outside native fields
[unit: device-position-history; auto: smoke_device_position_history].
- [ ] No Reset button (v1.33.2): the Device editor toolbar has three entry
tools — add, show all, icon rules — plus position Undo/Redo and Close;
the layout-wiping Reset is gone [auto: smoke_editor_tabs]
- [ ] Grid in all editors + decor fade (v1.33.1): the dot grid shows in the
Device and Background editors too (instant "I'm editing" cue), not in
View; in the Background editor rooms/devices/openings/labels plus solid,
+7 -1
View File
@@ -520,7 +520,13 @@ and its other entities remain deleted. The complete device stays available in
### Device editor
- drag a marker to save its server-side position;
- drag a marker to save its server-side position. One completed drag creates
one position-history step; a cancelled, unchanged or failed drag creates
none;
- persistent Undo/Redo buttons affect marker positions only. The same
session-local history is available through `Ctrl/Cmd+Z`,
`Ctrl/Cmd+Shift+Z` and `Ctrl+Y`, keeps up to 50 completed moves, and is not
restored after reopening the card;
- click it to edit name, binding, room, tap action and presentation;
- **Add** opens the new-device dialog directly, without going through the
device catalog;
+2 -1
View File
@@ -791,7 +791,8 @@ binding tombstone не вырезает её из живого устройст
| Действие | Результат |
|---|---|
| Перетащить маркер | Меняет его общую серверную позицию; центр всегда оказывается на узле сетки |
| Перетащить маркер | Меняет его общую серверную позицию; центр всегда оказывается на узле сетки. Одно завершённое перетаскивание создаёт один шаг истории, отменённое, неизменившее позицию или неудачное — ни одного |
| Отменить / Повторить | Постоянные кнопки панели меняют только позиции маркеров. Та же отдельная история доступна по `Ctrl/Cmd+Z`, `Ctrl/Cmd+Shift+Z` и `Ctrl+Y`, хранит до 50 завершённых перемещений текущей сессии и не восстанавливается после повторного открытия карточки |
| Клик по маркеру | Открывает диалог настройки |
| **Добавить** | Сразу открывает диалог нового устройства, без перехода в каталог |
| **Устройства** | Открывает единый каталог с поиском и вкладками «На плане», «Доступны», «Скрытые» и «Доступны снова»; у каждой привязки показаны причина, статус HA и допустимое следующее действие |
+6 -1
View File
@@ -161,8 +161,13 @@ layer you cannot see is a layer you cannot edit.
(binding, name, icon, size/angle, display as icon / icon + activity / value,
activity color and size, tap override,
model/link/description/PDFs, room).
- The toolbar always keeps position-only Undo/Redo beside Close. One released
drag is one command; cancellation, no movement and a failed write create no
command. The independent 50-step session stack also uses `Ctrl/Cmd+Z`,
`Ctrl/Cmd+Shift+Z` and `Ctrl+Y` and never absorbs marker configuration,
lifecycle or Plan/Background edits.
- + add device/entity/virtual, the bottom-left "Hide" / "Show" action in an
existing device dialog (the one hiding mechanism, docs/FILTERING.md), ↺ reset layout,
existing device dialog (the one hiding mechanism, docs/FILTERING.md),
👁 "Show hidden" (local editor tool; replaced the shared show-all toggle),
⬡ icon rules.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 288 KiB

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 277 KiB

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 246 KiB

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 241 KiB

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 105 KiB

+22 -22
View File
@@ -2,8 +2,8 @@
"version": 1,
"fixture": "synthetic-only",
"chromium": "151.0.7922.34",
"oxipng": "oxipng 10.2.0",
"sourceFingerprint": "06d233c045342dd141398dafca00d63123856cf36d84611513a22e81ba3843ba",
"oxipng": "oxipng 10.1.1",
"sourceFingerprint": "5cbe86c56ad056bab8b5d3efaee08859697b151843647800e6e4f8e70f556b76",
"captureScriptSha256": "fe76c8e2e27501e9a377d7aa15dd6cba6a018d55c07deb602dbee097f0a0a5ba",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -15,8 +15,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "06d233c045342dd141398dafca00d63123856cf36d84611513a22e81ba3843ba",
"imageSha256": "f8c9541c9d7fde462d0efba63dff70944dc03df76ce988eecaf2b269e2863a96"
"sourceSha256": "5cbe86c56ad056bab8b5d3efaee08859697b151843647800e6e4f8e70f556b76",
"imageSha256": "29dad0fc79acf981b261b9bdc453915205044504a170e36bdedbffe239b09f54"
},
"view-touch": {
"file": "02-view-touch.png",
@@ -26,8 +26,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "06d233c045342dd141398dafca00d63123856cf36d84611513a22e81ba3843ba",
"imageSha256": "21c6fe7d79ff5c878ade8e98a64cc90dfd7bcc66338c864fb5a0bd411471313d"
"sourceSha256": "5cbe86c56ad056bab8b5d3efaee08859697b151843647800e6e4f8e70f556b76",
"imageSha256": "aed210e9627e3c49d6895a03cc444c553cb789a229767ceab7ded19d7bddb15c"
},
"space-create": {
"file": "03-space-create.png",
@@ -37,8 +37,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "06d233c045342dd141398dafca00d63123856cf36d84611513a22e81ba3843ba",
"imageSha256": "fb32ea4a785fedb0e99b0bca6dbcf19022b32ced763ed875a57eb453be550bf5"
"sourceSha256": "5cbe86c56ad056bab8b5d3efaee08859697b151843647800e6e4f8e70f556b76",
"imageSha256": "16ad36b2dff1eace11b2946436e7e33a79ed11215cdcb084a8955e74275f8625"
},
"room-contour-close": {
"file": "04-room-contour-close.png",
@@ -48,8 +48,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "06d233c045342dd141398dafca00d63123856cf36d84611513a22e81ba3843ba",
"imageSha256": "ca8bb143d0d6679ff09f8e9c1f5ad68b2ca7cfd96a4740a4a650c251f55dfba5"
"sourceSha256": "5cbe86c56ad056bab8b5d3efaee08859697b151843647800e6e4f8e70f556b76",
"imageSha256": "16b6e383d2fca408de5c00cde9272c63f34686785dc4ef3e9bb99b524c616101"
},
"plan-context-tray": {
"file": "05-plan-context-tray.png",
@@ -59,8 +59,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "06d233c045342dd141398dafca00d63123856cf36d84611513a22e81ba3843ba",
"imageSha256": "06b67c5108f9ee29a226cc2920484ccffe0c79840aec09721072b25b91ef8f03"
"sourceSha256": "5cbe86c56ad056bab8b5d3efaee08859697b151843647800e6e4f8e70f556b76",
"imageSha256": "9ab93efc87e8d5d22525e97383f43c0baf22cdb8c3f75b8a7fa1608b200d42e9"
},
"device-editor": {
"file": "06-device-editor.png",
@@ -70,8 +70,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "06d233c045342dd141398dafca00d63123856cf36d84611513a22e81ba3843ba",
"imageSha256": "8be9c551b53bf072ee83b80963c9d9517348dc4d3856efe6ff0c1916e66f2c7e"
"sourceSha256": "5cbe86c56ad056bab8b5d3efaee08859697b151843647800e6e4f8e70f556b76",
"imageSha256": "16a8a765db13c9df12a04894926af805fd2166d1675b6a5c4ce51d30d9c06df7"
},
"device-display-preview": {
"file": "06-device-display-preview.png",
@@ -81,8 +81,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "06d233c045342dd141398dafca00d63123856cf36d84611513a22e81ba3843ba",
"imageSha256": "cc5abe1b3364bf9812bea6ea5cfa84f35120ff9639ed3c4c92958cb646a8caf7"
"sourceSha256": "5cbe86c56ad056bab8b5d3efaee08859697b151843647800e6e4f8e70f556b76",
"imageSha256": "17ec126f49d87340b4712422180f3341bec9070df0706af5d9dd29bed4f56c76"
},
"background-editor": {
"file": "07-background-editor.png",
@@ -92,8 +92,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "06d233c045342dd141398dafca00d63123856cf36d84611513a22e81ba3843ba",
"imageSha256": "d3b44c4f74c2f92d23e2346451f1a89a603ba6594e9ea32c9c1b53d0946fdad7"
"sourceSha256": "5cbe86c56ad056bab8b5d3efaee08859697b151843647800e6e4f8e70f556b76",
"imageSha256": "f258bbf604fc0b3e472a6c9bccc83874c2720f0e414a1a7b7439355a4a795ede"
},
"room-card": {
"file": "08-room-card.png",
@@ -103,8 +103,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "06d233c045342dd141398dafca00d63123856cf36d84611513a22e81ba3843ba",
"imageSha256": "f1bc10b478f7afc68e70cb4c37148f680b0bbd41d50a7d63a21067fc952ff134"
"sourceSha256": "5cbe86c56ad056bab8b5d3efaee08859697b151843647800e6e4f8e70f556b76",
"imageSha256": "208d8baf186f1983951022d98e2ad30931ff4f03bc455ea23c36160f6fd1e4b9"
},
"device-info": {
"file": "09-device-info.png",
@@ -114,8 +114,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "06d233c045342dd141398dafca00d63123856cf36d84611513a22e81ba3843ba",
"imageSha256": "2634cd7ead5720ce46ea4d6fa8f31006b740c520a73ddc97afe9a6d9d3b49778"
"sourceSha256": "5cbe86c56ad056bab8b5d3efaee08859697b151843647800e6e4f8e70f556b76",
"imageSha256": "43e70072d5bf2935547ded3cad7144a3fde88a856a80fcbcc86cf281c389a0cf"
}
}
}
+1 -1
View File
@@ -44,7 +44,7 @@ export function checkCoordinateWriteBarriers(root = defaultRoot) {
}
const positionWrites = occurrences(frontend, "type: 'houseplan/layout/update'");
if (positionWrites.length !== 2) errors.push(`frontend position writer inventory: ${positionWrites.length}`);
if (positionWrites.length !== 3) errors.push(`frontend position writer inventory: ${positionWrites.length}`);
for (const [ordinal, index] of positionWrites.entries()) requireWindow(
errors, frontend, index,
/const pos = canonicalizePosition\([^)]+\);[\s\S]*pos(?:[,\s}])/,
+13 -2
View File
@@ -2282,6 +2282,17 @@ const MUTANT_DEFINITIONS = [
replace: ' const exact = false ? exactCoveringWall(walls, a, b, pitch, coordScale) : null;',
}],
},
{
id: 'device-position-cancel-routed-to-commit',
guard: 'node demo/smoke_device_position_history.mjs',
because: 'pointer cancellation and lost capture must restore the uncommitted device preview '
+ 'without creating a position command or writing layout (#74)',
patches: [{
file: 'src/houseplan-card.ts',
find: '@pointercancel=${(e: PointerEvent) => this._pointerCancel(e, d)}',
replace: '@pointercancel=${(e: PointerEvent) => this._pointerUp(e, d)}',
}],
},
{
id: 'stale-space-position-guard-removed',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
@@ -2290,8 +2301,8 @@ const MUTANT_DEFINITIONS = [
+ 'and websocket side effects when that space has been deleted or renamed',
patches: [{
file: 'src/houseplan-card.ts',
find: ' if (!this._spaceModelById(d.space)) return;\n if (this._norm) {',
replace: ' if (this._norm) {',
find: ' if (!this._spaceModelById(d.space)) return;\n this._layout = applyDevicePlacement(',
replace: ' this._layout = applyDevicePlacement(',
}],
},
{
+11
View File
@@ -27,6 +27,17 @@
/** @type {SmokeLink[]} */
export const SMOKE_LINKS = [
{
symbols: [
'applyDevicePlacement', 'devicePlacement', 'sameDevicePlacement',
'DevicePlacement', 'DevicePositionState',
],
smokes: ['smoke_device_position_history.mjs'],
because: 'the #74 production-bundle scenario drives real Device editor pointer events and '
+ 'observes transaction boundaries, point-wise update/delete, toolbar history, native field '
+ 'shortcuts, cancellation and revision invalidation; none of those browser effects can be '
+ 'proved by the pure placement helpers alone',
},
{
symbols: [
'resolveValueSource', 'valueSourceWriteFields', 'valueBadgeCandidates',
+68
View File
@@ -0,0 +1,68 @@
export interface DevicePlacement {
x: number;
y: number;
s?: string;
}
export interface DeviceLayoutEntry extends Record<string, unknown> {
x: number;
y: number;
s?: string;
k?: number;
}
export type DeviceLayout = Record<string, DeviceLayoutEntry>;
export interface DevicePositionState {
deviceId: string;
spaceId: string;
placement: DevicePlacement | null;
}
export function devicePlacement(layout: DeviceLayout, deviceId: string): DevicePlacement | null {
const entry = layout[deviceId];
if (!entry || !Number.isFinite(entry.x) || !Number.isFinite(entry.y)) return null;
return {
x: entry.x,
y: entry.y,
...(typeof entry.s === 'string' ? { s: entry.s } : {}),
};
}
export function sameDevicePlacement(
left: DevicePlacement | null,
right: DevicePlacement | null,
): boolean {
if (left === null || right === null) return left === right;
return left.x === right.x && left.y === right.y && left.s === right.s;
}
/**
* Apply only placement fields. Existing/future sibling fields deliberately
* survive an update; a null placement removes the complete persisted position
* record because the layout schema does not permit a record without x/y.
*/
export function applyDevicePlacement(
layout: DeviceLayout,
deviceId: string,
placement: DevicePlacement | null,
): DeviceLayout {
if (placement === null) {
if (!(deviceId in layout)) return layout;
const next = { ...layout };
delete next[deviceId];
return next;
}
const current = { ...(layout[deviceId] || {}) } as Record<string, unknown>;
delete current.x;
delete current.y;
delete current.s;
const entry: DeviceLayoutEntry = {
...current,
x: placement.x,
y: placement.y,
...(placement.s !== undefined ? { s: placement.s } : {}),
};
return { ...layout, [deviceId]: entry };
}
+277 -50
View File
@@ -241,6 +241,14 @@ import { hasTranslation, langOf, t, type I18nKey } from './i18n';
import { LANGUAGE_RUNTIME, subscribeLanguageLoadFailures } from './i18n/registry';
import { languageLoadingTemplate, languageRenderGate } from './i18n/language-runtime';
import { CommandStack } from './command-stack';
import {
applyDevicePlacement,
devicePlacement,
sameDevicePlacement,
type DeviceLayout,
type DevicePlacement,
type DevicePositionState,
} from './device-position-history';
import { resolvedSvgScreenBlend, svgScreenBlendSupported } from './glow-blend';
import {
buildGlowClipGeometry, buildLightBarrierScene, createGlowRuntimeState,
@@ -789,6 +797,21 @@ interface DeviceInboxDialogState {
type FixedFloorState = FixedFloorSelection | { kind: 'pending'; value: unknown };
interface DeviceDragState {
id: string;
spaceId: string;
displayName: string;
pointerId: number;
source: Element | null;
sx: number;
sy: number;
ox: number;
oy: number;
moved: boolean;
before: DevicePlacement | null;
start: DevicePlacement;
}
export class HouseplanCard extends LitElement {
public _editorRuntime: import('./houseplan-editor-runtime').HouseplanEditorRuntime | null = null;
public _onboardingRuntime: import('./houseplan-onboarding-runtime').HouseplanOnboardingRuntime | null = null;
@@ -925,7 +948,7 @@ export class HouseplanCard extends LitElement {
private _config?: CardConfig;
private _space = 'f1';
private _layout: Record<string, { x: number; y: number; s?: string; k?: number }> = {};
private _layout: DeviceLayout = {};
private _serverStorage = false;
private _loadOk = false;
/** null until config/get answers; then mirrors auth.may_write for this user. */
@@ -1702,6 +1725,9 @@ export class HouseplanCard extends LitElement {
private _tool: MarkupTool = 'draw';
/** UX-04: one named, 50-step command history for every plan-geometry tool. */
private _geometryHistory = new CommandStack<SpaceGeometryState>(50);
/** #74: independent session-local history for manual device placements. */
private _devicePositionHistory = new CommandStack<DevicePositionState>(50);
private _devicePositionBusy = false;
/** Wall-thickness tool dialog (docs/WALL-THICKNESS.md). */
private _wallDialog: {
a: number[]; b: number[];
@@ -2401,6 +2427,7 @@ export class HouseplanCard extends LitElement {
};
private _drag: { id: string; sx: number; sy: number; ox: number; oy: number; moved: boolean } | null = null;
private _deviceDrag: DeviceDragState | null = null;
private _rlResize: { id: string; space: string; k0: number; cx: number; cy: number; d0: number } | null = null;
private _holdTimer?: number;
private _holdFired = false;
@@ -2642,6 +2669,7 @@ export class HouseplanCard extends LitElement {
this._bootTimer = undefined; // AUD-1552-01: a cleared id must not block the reconnect watcher
clearTimeout(this._bootSoftTimer);
this._saveConfigDebounced.flush(); // never leave an edit unsent on teardown
this._cancelDeviceDrag();
window.removeEventListener('hashchange', this._onHashChange);
this._labsUnsub?.();
this._labsUnsub = undefined;
@@ -2813,6 +2841,24 @@ export class HouseplanCard extends LitElement {
}
return;
}
if (this._mode === 'devices') {
if ((undo || redo) && inField) return;
if (redo) {
e.preventDefault();
this._redoDevicePosition();
return;
}
if (undo) {
e.preventDefault();
this._undoDevicePosition();
return;
}
if (e.key === 'Escape' && this._deviceDrag) {
e.preventDefault();
this._cancelDeviceDrag();
}
return;
}
if (!this._markup) return;
if ((undo || redo) && inField) return; // keep native text-field history
if ((e.key === 'Delete' || e.key === 'Backspace') && this._physicalSel
@@ -3810,6 +3856,7 @@ export class HouseplanCard extends LitElement {
this._pinchStart = null;
this._swipeStart = null;
this._drag = null;
this._deviceDrag = null;
this._rlResize = null;
this._vacFit = null;
this._compassDrag = false;
@@ -3817,6 +3864,7 @@ export class HouseplanCard extends LitElement {
this._mode = 'view';
this._clearGeometryGesture();
this._geometryHistory.clear();
this._devicePositionHistory.clear();
this._resumeDraftBySpace = {};
this._tip = null;
this._hoverRoom = null;
@@ -4045,6 +4093,8 @@ export class HouseplanCard extends LitElement {
// A genuinely different baseline invalidates local geometry undo. A
// reconnect echo with identical content deliberately does not.
this._geometryHistory.clear();
this._devicePositionHistory.clear();
this._cancelDeviceDrag();
this._pendingPhysicalWrites.clear();
if (this._serverCfg) this._clearGeometryGesture();
this._serverCfg = nextConfig;
@@ -4073,6 +4123,8 @@ export class HouseplanCard extends LitElement {
layoutChanged = nextLayoutFingerprint !== (this._layoutContentFingerprint
|| contentFingerprint(this._layout));
if (layoutChanged) {
this._devicePositionHistory.clear();
this._cancelDeviceDrag();
this._layout = nextLayout;
this._layoutContentFingerprint = nextLayoutFingerprint;
}
@@ -4741,12 +4793,18 @@ export class HouseplanCard extends LitElement {
const resp = await this.hass.callWS({ type: 'houseplan/layout/get' });
const remote = resp?.layout || {};
const merged: Record<string, any> = { ...remote };
for (const [id, pos] of mine) merged[id] = pos;
const fingerprint = contentFingerprint(merged);
if (fingerprint !== (this._layoutContentFingerprint || contentFingerprint(this._layout))) {
this._layout = merged;
this._layoutContentFingerprint = fingerprint;
for (const [id, pos] of mine) {
if (pos === null) delete merged[id];
else merged[id] = pos;
}
const fingerprint = contentFingerprint(merged);
const differsFromCurrent = fingerprint !== contentFingerprint(this._layout);
if (differsFromCurrent) {
this._cancelDeviceDrag();
this._devicePositionHistory.clear();
this._layout = merged;
}
this._layoutContentFingerprint = fingerprint;
this._layoutRev = resp?.rev ?? this._layoutRev;
this._canOptimizeUndo = !!resp?.can_optimize_undo;
this._haIntegrationVersion = typeof resp?.integration_version === 'string'
@@ -4764,7 +4822,12 @@ export class HouseplanCard extends LitElement {
private _dirtyPos = new Set<string>();
/** Positions sent to the server and not acknowledged yet (HP-1461-02). */
private _sentPos = new Map<string, { s?: string; x: number; y: number }>();
private _sentPos = new Map<string, DeviceLayout[string] | null>();
private _persistLocalLayout(): void {
this._layout = canonicalizeLayoutGeometry(this._layout);
localStorage.setItem(LS_KEY, JSON.stringify(this._layout));
}
private _persistLayout = debounce(() => {
if (this._serverStorage) {
@@ -4786,8 +4849,7 @@ export class HouseplanCard extends LitElement {
}
this._cacheSnapshot();
} else {
this._layout = canonicalizeLayoutGeometry(this._layout);
localStorage.setItem(LS_KEY, JSON.stringify(this._layout));
this._persistLocalLayout();
}
}, 600);
@@ -4850,8 +4912,10 @@ export class HouseplanCard extends LitElement {
|| this._devices.find((d) => d.id === this._infoCard?.id)?.bindingStatus?.kind === 'ha_disabled') {
this._closeInfoCard();
}
if (this._drag && this._devices.find((d) => d.id === this._drag!.id)?.bindingStatus?.kind === 'ha_disabled') {
this._drag = null;
if (this._deviceDrag
&& this._devices.find((d) => d.id === this._deviceDrag!.id)?.bindingStatus?.kind === 'ha_disabled') {
this._cancelDeviceDrag();
this._devicePositionHistory.clear();
}
clearTimeout(this._holdTimer);
this._holdFired = false;
@@ -4998,24 +5062,135 @@ export class HouseplanCard extends LitElement {
return snapPt(spaceCenter(s));
}
private _devicePlacementForCanvas(d: DevItem, x: number, y: number): DevicePlacement {
if (!this._norm) return { x: Math.round(x), y: Math.round(y) };
const g = this._gridPitch;
const gx = Math.round(x / g) * g;
const gy = Math.round(y / g) * g;
return {
s: d.space,
x: clampCanvasN(gx / NORM_W),
y: clampCanvasN(gy / NORM_W),
};
}
private _previewDevicePlacement(deviceId: string, placement: DevicePlacement | null): void {
this._layout = applyDevicePlacement(this._layout, deviceId, placement);
this.requestUpdate();
}
/** Abort an uncommitted device preview. Returns true when a drag existed. */
private _cancelDeviceDrag(): boolean {
const drag = this._deviceDrag;
if (!drag) return false;
this._deviceDrag = null;
try {
if (drag.source?.hasPointerCapture?.(drag.pointerId)) {
drag.source.releasePointerCapture(drag.pointerId);
}
} catch {
// Pointer capture may already have ended before the cancellation signal.
}
this._previewDevicePlacement(drag.id, drag.before);
return true;
}
/** One serial, point-wise position write used by drag commit and history. */
private async _persistDevicePlacement(
deviceId: string,
placement: DevicePlacement | null,
): Promise<void> {
this._layout = applyDevicePlacement(this._layout, deviceId, placement);
if (this._serverStorage) {
let pending: DeviceLayout[string] | null = null;
let registered = false;
try {
let response: unknown;
if (placement === null) {
pending = null;
this._sentPos.set(deviceId, pending);
registered = true;
response = await this.hass.callWS({
type: 'houseplan/layout/delete', device_id: deviceId,
});
} else {
const pos = canonicalizePosition(this._layout[deviceId]);
pending = pos;
this._sentPos.set(deviceId, pending);
registered = true;
response = await this.hass.callWS({
type: 'houseplan/layout/update', device_id: deviceId, pos,
});
}
this._noteLayoutRev(response);
this._layoutContentFingerprint = contentFingerprint(this._layout);
this._cacheSnapshot();
} finally {
if (registered && this._sentPos.get(deviceId) === pending) this._sentPos.delete(deviceId);
}
return;
}
this._persistLocalLayout();
}
private _devicePositionStateValid(state: DevicePositionState): boolean {
const device = this._devices.find((candidate) => candidate.id === state.deviceId);
return !!device
&& device.space === state.spaceId
&& device.bindingStatus?.kind !== 'ha_disabled'
&& !!this._spaceModelById(state.spaceId);
}
private async _runDevicePositionHistory(direction: 'undo' | 'redo'): Promise<void> {
if (this._cancelDeviceDrag() || this._devicePositionBusy) return;
const command = direction === 'undo'
? this._devicePositionHistory.undo()
: this._devicePositionHistory.redo();
if (!command) return;
const target = direction === 'undo' ? command.before : command.after;
if (!this._devicePositionStateValid(target)) {
this._devicePositionHistory.clear();
this._showToast(this._t('history.device_stale'));
this.requestUpdate();
return;
}
const rollback = devicePlacement(this._layout, target.deviceId);
if (target.spaceId !== this._space) {
this._commitSpace(target.spaceId);
this._restoreZoom();
}
this._devicePositionBusy = true;
this._previewDevicePlacement(target.deviceId, target.placement);
try {
await this._persistDevicePlacement(target.deviceId, target.placement);
this._showToast(this._t(direction === 'undo' ? 'history.undone' : 'history.redone', {
name: command.name,
}));
} catch (error: unknown) {
this._previewDevicePlacement(target.deviceId, rollback);
if (direction === 'undo') this._devicePositionHistory.redo();
else this._devicePositionHistory.undo();
this._showToast(this._t('toast.pos_save_failed', { err: this._errText(error) }));
} finally {
this._devicePositionBusy = false;
this.requestUpdate();
}
}
private _undoDevicePosition(): void {
void this._runDevicePositionHistory('undo');
}
private _redoDevicePosition(): void {
void this._runDevicePositionHistory('redo');
}
private _savePos(d: DevItem, x: number, y: number): void {
if (!this._spaceModelById(d.space)) return;
if (this._norm) {
// The icon center snaps to the nodes of the same grid as the room markup
// (docs/CANVAS.md §9). UX-05 has no free-position escape hatch.
const g = this._gridPitch;
const gx = Math.round(x / g) * g;
const gy = Math.round(y / g) * g;
const prevK = (this._layout[d.id] as any)?.k;
this._layout = {
...this._layout,
[d.id]: { s: d.space, x: clampCanvasN(gx / NORM_W), y: clampCanvasN(gy / NORM_W),
...(prevK ? { k: prevK } : {}) },
};
} else {
this._layout = { ...this._layout, [d.id]: { x: Math.round(x), y: Math.round(y) } };
}
this._layout = applyDevicePlacement(
this._layout, d.id, this._devicePlacementForCanvas(d, x, y),
);
this._dirtyPos.add(d.id);
this._persistLayout();
}
@@ -5191,7 +5366,7 @@ export class HouseplanCard extends LitElement {
// if a future CSS change accidentally makes a marker a hit target again.
if (this._mode !== 'view' && this._mode !== 'devices') return;
ev.stopPropagation();
if (this._drag?.moved || this._suppressClick || this._holdFired) return;
if (this._deviceDrag?.moved || this._suppressClick || this._holdFired) return;
if (this._mode === 'devices') {
this._openMarkerDialog(d);
return;
@@ -6103,7 +6278,7 @@ export class HouseplanCard extends LitElement {
}
}
// do not interfere with icon and label dragging
if (this._drag) return;
if (this._drag || this._deviceDrag) return;
if (this._markup) {
// Drawing is CLICK-based, so gestures coexist with it: a finger that
// MOVES pans, two fingers pinch, and _suppressClick keeps the release
@@ -6370,26 +6545,44 @@ export class HouseplanCard extends LitElement {
}
// A disabled ghost is a service entry point, not a movable plan object.
// Leave the click intact so the settings dialog still opens.
if (d.bindingStatus?.kind === 'ha_disabled') return;
if (d.bindingStatus?.kind === 'ha_disabled' || this._devicePositionBusy) return;
if (this._deviceDrag) {
if (this._deviceDrag.pointerId !== ev.pointerId) this._cancelDeviceDrag();
return;
}
ev.preventDefault();
const p = this._pos(d);
this._drag = { id: d.id, sx: ev.clientX, sy: ev.clientY, ox: p.x, oy: p.y, moved: false };
this._deviceDrag = {
id: d.id,
spaceId: d.space,
displayName: d.name,
pointerId: ev.pointerId,
source: ev.currentTarget as Element | null,
sx: ev.clientX,
sy: ev.clientY,
ox: p.x,
oy: p.y,
moved: false,
before: devicePlacement(this._layout, d.id),
start: this._devicePlacementForCanvas(d, p.x, p.y),
};
capturePointer(ev);
this._tip = null;
}
private _pointerMove(ev: PointerEvent, d: DevItem): void {
if (this._mode !== 'devices') return;
if (!this._drag || this._drag.id !== d.id) return;
const drag = this._deviceDrag;
if (!drag || drag.id !== d.id || drag.pointerId !== ev.pointerId) return;
const stage = this.renderRoot.querySelector('.stage') as HTMLElement;
if (!stage) return;
const vb = this._baseVb();
const rect = stage.getBoundingClientRect();
const v = this._viewOr(vb);
const dx = ((ev.clientX - this._drag.sx) / rect.width) * v.w;
const dy = ((ev.clientY - this._drag.sy) / rect.height) * v.h;
if (Math.abs(ev.clientX - this._drag.sx) + Math.abs(ev.clientY - this._drag.sy) > 3) {
this._drag.moved = true;
const dx = ((ev.clientX - drag.sx) / rect.width) * v.w;
const dy = ((ev.clientY - drag.sy) / rect.height) * v.h;
if (Math.abs(ev.clientX - drag.sx) + Math.abs(ev.clientY - drag.sy) > 3) {
drag.moved = true;
clearTimeout(this._holdTimer);
}
// DEV-B58-01. This used to be clamped into `vb` — the CONTENT FRAME — with
@@ -6399,21 +6592,54 @@ export class HouseplanCard extends LitElement {
// where the new room was going to be. The plan has no edges any more
// (docs/CANVAS.md §9); the only bound is the garbage limit the backend
// enforces, and it is the SAME ±5000 on both sides of the wire.
const nx = clampCanvasR(this._drag.ox + dx);
const ny = clampCanvasR(this._drag.oy + dy);
this._savePos(d, nx, ny);
const nx = clampCanvasR(drag.ox + dx);
const ny = clampCanvasR(drag.oy + dy);
this._previewDevicePlacement(d.id, this._devicePlacementForCanvas(d, nx, ny));
}
private _pointerUp(_ev: PointerEvent, d: DevItem): void {
private _pointerUp(ev: PointerEvent, d: DevItem): void {
clearTimeout(this._holdTimer);
if (this._mode !== 'devices') return;
if (!this._drag || this._drag.id !== d.id) return;
const moved = this._drag.moved;
this._drag = moved ? this._drag : null;
if (moved) {
this._selId = d.id;
window.setTimeout(() => (this._drag = null), 0);
const drag = this._deviceDrag;
if (!drag || drag.id !== d.id || drag.pointerId !== ev.pointerId) return;
this._deviceDrag = null;
const after = devicePlacement(this._layout, d.id);
if (!drag.moved || after === null || sameDevicePlacement(after, drag.start)) {
this._previewDevicePlacement(d.id, drag.before);
return;
}
this._selId = d.id;
this._suppressClick = true;
window.setTimeout(() => { this._suppressClick = false; }, 0);
this._devicePositionBusy = true;
this.requestUpdate();
void this._persistDevicePlacement(d.id, after)
.then(() => {
const name = this._t('history.device_move', { name: drag.displayName });
this._devicePositionHistory.push({
name,
before: {
deviceId: drag.id, spaceId: drag.spaceId, placement: drag.before,
},
after: {
deviceId: drag.id, spaceId: drag.spaceId, placement: after,
},
});
})
.catch((error: unknown) => {
this._previewDevicePlacement(d.id, drag.before);
this._showToast(this._t('toast.pos_save_failed', { err: this._errText(error) }));
})
.finally(() => {
this._devicePositionBusy = false;
this.requestUpdate();
});
}
private _pointerCancel(ev: PointerEvent, d: DevItem): void {
if (this._deviceDrag?.id !== d.id || this._deviceDrag.pointerId !== ev.pointerId) return;
this._cancelDeviceDrag();
}
private _showToast(msg: string): void {
@@ -6576,7 +6802,7 @@ export class HouseplanCard extends LitElement {
): void {
this._notePointer(ev);
if (!this._pointerModality.hoverEnabled) return;
if (this._drag) return;
if (this._drag || this._deviceDrag) return;
this._tip = { x: ev.clientX, y: ev.clientY, title, meta, lqi, temp, hum };
}
@@ -11804,7 +12030,8 @@ export class HouseplanCard extends LitElement {
this._showTip(e, d.name, presentation.haDisabled ? ghostLabel : metrics);
}}
@pointerup=${(e: PointerEvent) => this._pointerUp(e, d)}
@pointercancel=${(e: PointerEvent) => this._pointerUp(e, d)}
@pointercancel=${(e: PointerEvent) => this._pointerCancel(e, d)}
@lostpointercapture=${(e: PointerEvent) => this._pointerCancel(e, d)}
>
${renderDeviceFace(presentation, {
surface: 'interactive-plan',
@@ -12127,8 +12354,8 @@ export class HouseplanCard extends LitElement {
}
return null;
}
if (this._mode === 'devices' && this._drag?.moved) {
const d = this._devices.find((x) => x.id === this._drag!.id);
if (this._mode === 'devices' && this._deviceDrag?.moved) {
const d = this._devices.find((x) => x.id === this._deviceDrag!.id);
return d ? (() => { const p = this._pos(d); return [p.x, p.y]; })() : null;
}
if (this._mode === 'decor') {
+37 -2
View File
@@ -245,6 +245,7 @@ import { enqueueSerializedWrite } from './serialized-write-queue';
import { hasTranslation, langOf, t, type I18nKey } from './i18n';
import { classifyPlanFile, encodePlanFile, renderBackdropGuard } from './backdrop-pick';
import { CommandStack } from './command-stack';
import type { DeviceLayout, DevicePositionState } from './device-position-history';
import { resolvedSvgScreenBlend, svgScreenBlendSupported } from './glow-blend';
import {
CONTINUITY_LONG_HIDDEN_MS,
@@ -854,6 +855,7 @@ export interface HouseplanEditorHostPort {
_canEdit: boolean;
_canOptimizeUndo: boolean;
_cancelDevicePressFeedback: () => void;
_cancelDeviceDrag: () => boolean;
_cancelModeTransition: (commitTarget?: boolean) => void;
_candidateDeviceSnapshot: RenderDeviceSnapshot | null;
_capturedSnapshotConfigEpoch: number;
@@ -898,6 +900,8 @@ export interface HouseplanEditorHostPort {
_deviceInboxMemo: { key: string; rows: DeviceInboxRow[]; } | null;
_deviceInboxReturn: DeviceInboxDialogState | null;
_devicePresentation: (d: DevItem, showLqi?: boolean, designPreview?: boolean) => ResolvedDevicePresentation;
_devicePositionBusy: boolean;
_devicePositionHistory: CommandStack<DevicePositionState>;
_devices: DevItem[];
_dirtyPos: Set<string>;
_display: (url: string | null | undefined) => string;
@@ -963,7 +967,7 @@ export interface HouseplanEditorHostPort {
_kioskScale: { icon: number; font: number; };
_labsIso: boolean;
_lastValidStageSize: [number, number] | null;
_layout: Record<string, { x: number; y: number; s?: string; k?: number; }>;
_layout: DeviceLayout;
_layoutRev: number;
_logicalViewCenter: (projection: "flat" | "iso") => { x: number; y: number; } | null;
_markerDialog: { devId?: string; uploadId?: string; name: string; binding: string; bindingMode: "virtual" | "ha"; bindingOpen: boolean; showEntities: boolean; bindingFilter: string; icon: string; autoIcon: string; display: DeviceDisplayMode; rippleColor: string; rippleSize: number; size: number; angle: number; tapAction: string; tapActionTouched: boolean; originalHasTapAction: boolean; originalTapAction: string | null | undefined; tapHintAnnouncement: string; toggleEntity: string; toggleEntityTouched: boolean; originalHasToggleEntity: boolean; originalToggleEntity: string | null | undefined; tapTarget: string; tapConfirm: boolean; runFilter: string; controls: string[]; controlsFilter: string; glowRadius: string; lightRole: "auto" | "always" | "never"; lightRoleTouched: boolean; originalHasIsLight: boolean; originalIsLight: boolean | null | undefined; lightEntity: string; lightEntityTouched: boolean; originalHasLightEntity: boolean; originalLightEntity: string | null | undefined; glowMode: "auto" | "color" | "fixed"; glowColor: string; glowBrightness: number; glowColorDrafted: boolean; glowBrightnessDrafted: boolean; glowTouched: boolean; originalHasGlowColor: boolean; originalGlowColor: { c: string; bri?: number | null; } | null | undefined; valueBadgeEnabled: boolean; valueBadgeSource: ValueBadgeSource | null; valueBadgePosition: ValueBadgePosition; valueBadgeTouched: boolean; originalHasValueBadge: boolean; originalValueBadge: MarkerValueBadge | null | undefined; valueSource: ValueBadgeSource | null; valueSourceTouched: boolean; originalHasValueSource: boolean; originalValueSource: ValueBadgeSource | null | undefined; useClimateTemp: boolean; model: string; link: string; description: string; pdfs: PdfRef[]; room: string; hideFromPlan: boolean; busy: boolean; } | null;
@@ -1052,6 +1056,7 @@ export interface HouseplanEditorHostPort {
ResizePreview, ResizeLiveLabel[], SpaceGeometryState, ResizeWallUnion, ResizeWallArtifact
>;
_restoreZoom: () => void;
_redoDevicePosition: () => void;
_resumeDraftBySpace: Record<string, string>;
_rlResize: { id: string; space: string; k0: number; cx: number; cy: number; d0: number; } | null;
_roomCenter: (r: RoomCfg) => number[];
@@ -1075,7 +1080,7 @@ export interface HouseplanEditorHostPort {
_screenToVb: (sx: number, sy: number) => number[];
_segments: number[][];
_selId: string | null;
_sentPos: Map<string, { s?: string; x: number; y: number; }>;
_sentPos: Map<string, DeviceLayout[string] | null>;
_serverCfg: ServerConfig | null;
_serverStorage: boolean;
_settings: { exclude_integrations?: string[]; group_lights?: boolean; show_all?: boolean; filter_seeded?: boolean; icon_rules?: { pattern: string; icon: string; }[]; };
@@ -1097,6 +1102,7 @@ export interface HouseplanEditorHostPort {
_suppressClick: boolean;
_swipeStart: { x: number; y: number; id: number; } | null;
_t: (key: I18nKey, vars?: Record<string, string | number>) => string;
_undoDevicePosition: () => void;
_thickWallCuts: () => number[][];
_tip: { x: number; y: number; title: string; meta: string; lqi?: number | null; temp?: number | null; hum?: number | null; } | null;
_toggleConfirmationLines: (intent: ResolvedToggleIntent) => string[];
@@ -1212,6 +1218,7 @@ public _setMode(mode: 'view' | 'plan' | 'devices' | 'decor', animate = true): vo
}
return;
}
if (this.host._mode === 'devices') this.host._cancelDeviceDrag();
this.host._bootSoftCancel(); // navigation owns its own short, bounded transition
if ((mode === 'plan' || mode === 'decor') && !this.host._norm) {
this.host._showToast(this.host._t('toast.markup_needs_server'));
@@ -8315,6 +8322,8 @@ public async _saveMarker(): Promise<void> {
.catch(() => undefined); // leftovers are harmless; broken links are not
}
this._closeMarkerDialog();
this.host._cancelDeviceDrag();
this.host._devicePositionHistory.clear();
this.host._regSignature = '';
this.host._maybeRebuildDevices();
this.host._showToast(this.host._t('toast.marker_saved'));
@@ -8377,6 +8386,8 @@ public async _deleteMarker(): Promise<void> {
this.host._deviceInboxReturn = { ...this.host._deviceInboxReturn, tab: 'readd', anchor: binding };
}
this._closeMarkerDialog();
this.host._cancelDeviceDrag();
this.host._devicePositionHistory.clear();
if (this.host._infoCard?.id === targetId) this.host._closeInfoCard();
if (this.host._selId === targetId) this.host._selId = null;
if (this.host._drag?.id === targetId) this.host._drag = null;
@@ -9127,6 +9138,8 @@ public async _runAlignToGrid(): Promise<void> {
this.host._serverCfg = d.config;
this.host._layout = d.layout;
this.host._geometryHistory.clear();
this.host._cancelDeviceDrag();
this.host._devicePositionHistory.clear();
this.host._cfgRev = resp?.config_rev ?? this.host._cfgRev + 1;
this.host._layoutRev = resp?.layout_rev ?? this.host._layoutRev + 1;
this.host._canOptimizeUndo = !!resp?.can_undo;
@@ -9187,6 +9200,8 @@ public async _undoPlanOptimization(): Promise<void> {
this.host._cfgRev = cfgResp?.rev ?? this.host._cfgRev;
this.host._layout = layResp?.layout || this.host._layout;
this.host._geometryHistory.clear();
this.host._cancelDeviceDrag();
this.host._devicePositionHistory.clear();
this.host._layoutRev = layResp?.rev ?? this.host._layoutRev;
this.host._canOptimizeUndo = false;
this.host._undoKind = null;
@@ -11741,6 +11756,14 @@ public _renderMarkupBar(): TemplateResult {
}
public _renderDevicesBar(): TemplateResult {
const undoName = this.host._devicePositionHistory.undoName;
const redoName = this.host._devicePositionHistory.redoName;
const undoTitle = undoName
? this.host._t('history.undo_named', { name: undoName })
: this.host._t('history.undo_empty');
const redoTitle = redoName
? this.host._t('history.redo_named', { name: redoName })
: this.host._t('history.redo_empty');
return html`<div class="editbar devbar">
<div class="editbar-tools" tabindex="-1" ?inert=${this.host._modeTransitionBusy}>
<ha-icon icon="mdi:tune-variant" class="warn"></ha-icon>
@@ -11758,6 +11781,18 @@ public _renderDevicesBar(): TemplateResult {
${this.host._editorToolbarGroups.map((group) => this._renderEditorGroupLauncher(group))}
</div>
<div class="editbar-end">
<button class="btn ghost" data-device-position-history="undo"
@click=${() => this.host._undoDevicePosition()}
?disabled=${this.host._devicePositionBusy || !undoName}
title=${undoTitle} aria-label=${undoTitle}>
<ha-icon icon="mdi:undo-variant" aria-hidden="true"></ha-icon>
</button>
<button class="btn ghost" data-device-position-history="redo"
@click=${() => this.host._redoDevicePosition()}
?disabled=${this.host._devicePositionBusy || !redoName}
title=${redoTitle} aria-label=${redoTitle}>
<ha-icon icon="mdi:redo-variant" aria-hidden="true"></ha-icon>
</button>
<button class="btn barclose" title=${this.host._t('title.close_editor')}
data-editor-navigation="view"
@click=${() => this._setMode('view')}>
+2
View File
@@ -126,6 +126,8 @@
"history.decor_transform": "Dekorobjekt transformieren",
"history.decor_delete": "Dekorobjekt löschen",
"history.backdrop_transform": "Plan-Hintergrund transformieren",
"history.device_move": "{name} verschieben",
"history.device_stale": "Der Positionsverlauf ist nicht mehr anwendbar und wurde gelöscht",
"opening.new": "Neue Öffnung",
"opening.edit": "Öffnung",
"opening.door": "Tür",
+2
View File
@@ -126,6 +126,8 @@
"history.decor_transform": "Transform decor object",
"history.decor_delete": "Delete decor object",
"history.backdrop_transform": "Transform plan backdrop",
"history.device_move": "Move {name}",
"history.device_stale": "Device position history is no longer applicable and was cleared",
"opening.new": "New opening",
"opening.edit": "Opening",
"opening.door": "Door",
+2
View File
@@ -126,6 +126,8 @@
"history.decor_transform": "Transformer l’objet décoratif",
"history.decor_delete": "Supprimer l’objet décoratif",
"history.backdrop_transform": "Transformer l’arrière-plan du plan",
"history.device_move": "Déplacer {name}",
"history.device_stale": "L’historique des positions n’est plus applicable et a été effacé",
"opening.new": "Nouvelle ouverture",
"opening.edit": "Ouverture",
"opening.door": "Porte",
+2
View File
@@ -126,6 +126,8 @@
"history.decor_transform": "Трансформация объекта декора",
"history.decor_delete": "Удаление объекта декора",
"history.backdrop_transform": "Трансформация подложки плана",
"history.device_move": "Перемещение: {name}",
"history.device_stale": "История позиций больше неприменима и была очищена",
"opening.new": "Новый проём",
"opening.edit": "Проём",
"opening.door": "Дверь",
+55
View File
@@ -0,0 +1,55 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyDevicePlacement,
devicePlacement,
sameDevicePlacement,
} from '../test-build/device-position-history.js';
test('updates only placement and preserves zero and future sibling fields', () => {
const original = {
device: { s: 'floor-1', x: 0.1, y: 0.2, k: 0, future: { pinned: true } },
};
const next = applyDevicePlacement(original, 'device', {
s: 'floor-2', x: 0.75, y: 0.5,
});
assert.deepEqual(next.device, {
s: 'floor-2', x: 0.75, y: 0.5, k: 0, future: { pinned: true },
});
assert.deepEqual(original.device, {
s: 'floor-1', x: 0.1, y: 0.2, k: 0, future: { pinned: true },
});
assert.notEqual(next, original);
assert.notEqual(next.device, original.device);
});
test('legacy placement removes a newer space field without touching siblings', () => {
const next = applyDevicePlacement({
device: { s: 'floor-1', x: 0.1, y: 0.2, k: 3 },
}, 'device', { x: 120, y: 240 });
assert.deepEqual(next.device, { x: 120, y: 240, k: 3 });
assert.deepEqual(devicePlacement(next, 'device'), { x: 120, y: 240 });
});
test('null restores auto-position by removing the complete persisted record', () => {
const original = {
device: { s: 'floor-1', x: 0.1, y: 0.2, k: 0, future: 'value' },
other: { s: 'floor-1', x: 0.3, y: 0.4 },
};
const next = applyDevicePlacement(original, 'device', null);
assert.equal('device' in next, false);
assert.deepEqual(next.other, original.other);
assert.equal(devicePlacement(next, 'device'), null);
});
test('missing delete is an identity no-op and placement equality includes space', () => {
const layout = { other: { x: 1, y: 2 } };
assert.equal(applyDevicePlacement(layout, 'missing', null), layout);
assert.equal(sameDevicePlacement(null, null), true);
assert.equal(sameDevicePlacement(null, { x: 1, y: 2 }), false);
assert.equal(sameDevicePlacement({ x: 1, y: 2 }, { x: 1, y: 2 }), true);
assert.equal(sameDevicePlacement(
{ s: 'a', x: 1, y: 2 }, { s: 'b', x: 1, y: 2 },
), false);
});
+1 -1
View File
@@ -27,7 +27,7 @@
"src/signing.ts", "src/initial-load.ts", "src/space-model-selection.ts", "src/space-dialog.ts",
"src/visual-continuity.ts", "src/mode-transition.ts", "src/editor-runtime-loader.ts", "src/pointer-modality.ts",
"src/render-device-snapshot.ts",
"src/command-stack.ts",
"src/command-stack.ts", "src/device-position-history.ts",
"src/coordinate-canonicalization.ts",
"src/serialized-write-queue.ts",
"src/align-grid.ts",