import { html, nothing, svg, type TemplateResult } from 'lit'; import { classifyPlanFile } from './backdrop-pick'; import { adoptDecorAssets, initialDecorImageCm, type DecorAsset, } from './decor-assets'; import { FURNITURE_GROUPS, furnitureGraphic, furnitureOfGroup, furnitureSignedFieldCm, furnitureSignedFieldValue, type FurnitureGroup, } from './furniture'; import { GENERATED_FURNITURE_MENU } from './furniture-menu-art.generated'; import type { HouseplanEditorHostPort } from './houseplan-editor-runtime'; import type { I18nKey } from './i18n'; import { CANVAS_LIMIT, NORM_W, clampCanvasN } from './space-geometry'; import type { DecorShape } from './editors/decor/types'; import { decorCmToUnits } from './editors/decor/geometry'; interface DecorImageEditorHooks { decorSnap: (raw: number[], pointerType?: string) => number[]; geometrySnapshot: () => Snapshot; clearFurniturePreview: () => void; recordGeometry: (name: string, before: Snapshot) => void; saveConfig: () => void; saveShape: () => void; setGuardReplace: (replace: boolean | null) => void; furnShiftDetach: () => void; furnPick: (symbol: string) => void; furnFieldValue: (cm: number) => number; furnFieldToCm: (value: number) => number; } /** * Asset lifecycle and the two large decor palettes live outside the editor * runtime core. The runtime owns orchestration; this controller owns the * self-contained image/furniture UI introduced by #51. */ export class DecorImageEditor { public constructor( private readonly host: HouseplanEditorHostPort, private readonly hooks: DecorImageEditorHooks, ) {} public place(raw: number[], pointerType = 'mouse'): void { const asset = this.host._decorImagePalette; const sp = this.host._curSpaceCfg; if (!asset || !sp) return; const at = this.hooks.decorSnap(raw, pointerType); const size = initialDecorImageCm(asset.width, asset.height); const w = decorCmToUnits(size.w, this.host._cellCm, this.host._gridPitch) / NORM_W; const h = decorCmToUnits(size.h, this.host._cellCm, this.host._gridPitch) / this.host._decorH; const id = 'di' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5); const before = this.hooks.geometrySnapshot(); sp.decor = [...this.host._decorList, { id, kind: 'image', asset_id: asset.asset_id, x: clampCanvasN(at[0] / NORM_W - w / 2), y: clampCanvasN(at[1] / this.host._decorH - h / 2), w, h, opacity: 1, } as DecorShape]; this.host._decorSel = id; this.host._decorTool = 'select'; this.host._decorImagePalette = null; this.hooks.clearFurniturePreview(); this.hooks.recordGeometry(this.host._t('history.decor_add'), before); this.hooks.saveConfig(); this.host.requestUpdate(); } public renderPlacementPreview(): TemplateResult | typeof nothing { const input = this.host._furnPreviewInput; const asset = this.host._decorImagePalette; if (!input || !asset || this.host._mode !== 'decor' || this.host._decorTool !== 'image' || !this.host._pointerModality.hoverEnabled) { return nothing; } const at = this.hooks.decorSnap(input.raw, 'mouse'); const cm = initialDecorImageCm(asset.width, asset.height); const w = decorCmToUnits(cm.w, this.host._cellCm, this.host._gridPitch) / NORM_W; const h = decorCmToUnits(cm.h, this.host._cellCm, this.host._gridPitch) / this.host._decorH; const href = this.host._display(asset.url); if (!href) return nothing; return svg``; } public renderMissing( shape: Extract, cls: string, transform: string, x: number, y: number, w: number, h: number, down: (event: PointerEvent) => void, dbl: (event: MouseEvent) => void, ): TemplateResult { const cx = x + w / 2; const cy = y + h / 2; return svg` ${this.host._t('decor.image_unavailable')} ${this.host._t('decor.image_unavailable')} `; } public async uploadFromInput(ev: Event, replaceSelection = false): Promise { const input = ev.target as HTMLInputElement; const file = input.files?.[0]; input.value = ''; if (!file || this.host._decorAssetBusy) return; let classified: Awaited>; try { classified = await classifyPlanFile(file, 2 * 1024 * 1024); } catch { this.host._showToast(this.host._t('backup.error.invalid_image')); return; } if (classified.kind === 'reject') { this.host._showToast(this.host._t('toast.plan_formats')); return; } if (classified.kind === 'guard') { this.hooks.setGuardReplace(replaceSelection); this.host._backdropGuard = classified.state; return; } await this.upload(file, file.name, replaceSelection); } public async upload(file: Blob, name: string, replaceSelection: boolean): Promise { if (this.host._decorAssetBusy) return; this.host._decorAssetBusy = true; try { const body = new FormData(); body.append('file', file, name); const response: Response = this.host.hass?.fetchWithAuth ? await this.host.hass.fetchWithAuth('/api/houseplan/assets/upload', { method: 'POST', body }) : await fetch('/api/houseplan/assets/upload', { method: 'POST', body, headers: this.host.hass?.auth?.data?.access_token ? { authorization: `Bearer ${this.host.hass.auth.data.access_token}` } : {}, }); const data = await response.json().catch(() => ({})); if (!response.ok || data.error || !data.asset) { const messages: Record = { invalid_format: 'backup.error.invalid_image', invalid_image: 'backup.error.invalid_image', unsupported_image: 'backup.error.unsupported_image', too_large: 'backdrop.too_large_title', capacity_exceeded: 'decor.image_error_capacity', }; throw new Error(this.host._t(messages[String(data.error)] || 'backup.error.io_error')); } const asset = adoptDecorAssets({ assets: [data.asset] }).values().next().value as DecorAsset | undefined; if (!asset) throw new Error(this.host._t('backup.error.io_error')); this.host._decorAssets = new Map(this.host._decorAssets).set(asset.asset_id, asset); this.host._decorAssetCatalog = [ asset, ...this.host._decorAssetCatalog.filter((item) => item.asset_id !== asset.asset_id), ]; if (replaceSelection && this.host._decorShapeDialog?.kind === 'image') { this.host._decorShapeDialog = { ...this.host._decorShapeDialog, assetId: asset.asset_id }; } else { this.host._decorImagePalette = asset; } this.host.requestUpdate(); } catch (error) { this.host._showToast(this.host._t('decor.image_upload_failed', { err: this.host._errText(error), })); } finally { this.host._decorAssetBusy = false; } } public async delete(asset: DecorAsset): Promise { if (asset.used_by?.length) { this.host._showToast(this.host._t('decor.image_in_use')); return; } const accepted = await this.host._confirmDanger({ key: `delete-decor-asset:${asset.asset_id}`, kind: 'destructive', title: this.host._t('decor.image_delete_title'), message: this.host._t('decor.image_delete_message', { name: asset.name }), confirmLabel: this.host._t('btn.delete'), cancelLabel: this.host._t('btn.cancel'), }); if (!accepted) return; try { await this.host.hass.callWS({ type: 'houseplan/assets/delete', asset_id: asset.asset_id }); this.host._decorAssetCatalog = this.host._decorAssetCatalog.filter( (item) => item.asset_id !== asset.asset_id, ); this.host._decorAssets.delete(asset.asset_id); if (this.host._decorImagePalette?.asset_id === asset.asset_id) { this.host._decorImagePalette = null; } this.host.requestUpdate(); } catch (error) { this.host._showToast((error as { code?: string })?.code === 'in_use' ? this.host._t('decor.image_in_use') : this.host._t('backup.error.io_error')); } } public async loadCatalog(): Promise { try { const response = await this.host.hass.callWS({ type: 'houseplan/assets/list' }); const catalog = adoptDecorAssets(response); this.host._decorAssetCatalog = [...catalog.values()]; this.host._decorAssets = new Map([...this.host._decorAssets, ...catalog]); this.host.requestUpdate(); } catch { this.host._showToast(this.host._t('backup.error.io_error')); } } public renderImagePalette(): TemplateResult { const armed = this.host._decorImagePalette; return html``; } public renderFurniturePalette(): TemplateResult { const pal = this.host._furnPalette; const categoryId = this.host._furnCategory; const allSymbols = FURNITURE_GROUPS.flatMap((group) => furnitureOfGroup(group)); const categories = GENERATED_FURNITURE_MENU.filter((item) => allSymbols.some((symbol) => symbol.category === item.id)); const category = categories.find((item) => item.id === categoryId) || null; const variants = category ? allSymbols.filter((symbol) => symbol.category === category.id) : []; const unit = this.host._t(this.host._imperial ? 'gs.unit_ft' : 'gs.unit_m'); const preview = (id: string): TemplateResult => { const art = furnitureGraphic(id); if (!art) { return svg`` as unknown as TemplateResult; } return svg`` as unknown as TemplateResult; }; const categoryPreview = (item: (typeof GENERATED_FURNITURE_MENU)[number]): TemplateResult => svg`` as unknown as TemplateResult; return html`
event.stopPropagation()}>
${this.host._t('furn.title')}
${category ? html`
${this.host._t(`furn.cat_${category.id}` as I18nKey)}
${variants.map((symbol) => html``)}
` : FURNITURE_GROUPS.map((group) => html`
${this.host._t(`furn.group_${group}` as I18nKey)}
${categories.filter((item) => item.group === group).map((item) => html``)}
`)}
${pal ? html`
(this.host._furnPalette = { ...pal, w: this.hooks.furnFieldToCm(Number((event.target as HTMLInputElement).value)), })} /> (this.host._furnPalette = { ...pal, h: this.hooks.furnFieldToCm(Number((event.target as HTMLInputElement).value)), })} /> ${this.host._t('furn.place_hint')}
` : html`
${this.host._t( 'furn.pick_hint', )}
`}
`; } private furnitureSizeInput(axis: 'w' | 'h', raw: string): void { const dialog = this.host._decorShapeDialog; if (!dialog || (dialog.kind !== 'furniture' && dialog.kind !== 'image')) return; const cm = furnitureSignedFieldCm( raw, this.host._imperial, CANVAS_LIMIT * this.host._cellCm, ); const field = axis === 'w' ? 'sizeWField' : 'sizeHField'; const size = axis === 'w' ? 'sizeWCm' : 'sizeHCm'; const flip = axis === 'w' ? 'flipH' : 'flipV'; this.host._decorShapeDialog = { ...dialog, [field]: raw, ...(cm === null ? {} : { [size]: Math.abs(cm), [flip]: cm < 0 }), }; } private furnitureFlip(axis: 'w' | 'h', checked: boolean): void { const dialog = this.host._decorShapeDialog; if (!dialog || (dialog.kind !== 'furniture' && dialog.kind !== 'image')) return; const raw = axis === 'w' ? dialog.sizeWField : dialog.sizeHField; const parsed = furnitureSignedFieldCm( raw, this.host._imperial, CANVAS_LIMIT * this.host._cellCm, ); const fallback = axis === 'w' ? dialog.sizeWCm : dialog.sizeHCm; const cm = Math.abs(parsed ?? fallback ?? 0.1); const field = axis === 'w' ? 'sizeWField' : 'sizeHField'; const flip = axis === 'w' ? 'flipH' : 'flipV'; this.host._decorShapeDialog = { ...dialog, [field]: furnitureSignedFieldValue(cm, checked, this.host._imperial), [flip]: checked, }; } public renderShapeDialog(): TemplateResult { const dialog = this.host._decorShapeDialog!; const canFill = dialog.kind === 'rect' || dialog.kind === 'ellipse'; const kindLabel = this.host._t(`decor.${dialog.kind}` as I18nKey); const unit = this.host._t(this.host._imperial ? 'gs.unit_ft' : 'gs.unit_m'); const furnitureWcm = dialog.kind === 'furniture' || dialog.kind === 'image' ? furnitureSignedFieldCm( dialog.sizeWField, this.host._imperial, CANVAS_LIMIT * this.host._cellCm, ) : null; const furnitureHcm = dialog.kind === 'furniture' || dialog.kind === 'image' ? furnitureSignedFieldCm( dialog.sizeHField, this.host._imperial, CANVAS_LIMIT * this.host._cellCm, ) : null; const invalidFurnitureSize = (dialog.kind === 'furniture' || dialog.kind === 'image') && (furnitureWcm === null || furnitureHcm === null); const selectedImage = dialog.kind === 'image' ? this.host._decorAssetCatalog.find((asset) => asset.asset_id === dialog.assetId) || this.host._decorAssets.get(dialog.assetId || '') : null; return html` (this.host._decorShapeDialog = null)}>
${dialog.kind === 'furniture' ? html` ` : dialog.kind === 'image' ? html` ${selectedImage ? html`
${selectedImage.name} ${selectedImage.name}
` : nothing} ` : nothing} ${dialog.kind === 'image' ? html` (this.host._decorShapeDialog = { ...dialog, opacity: Number((event.target as HTMLInputElement).value), })} />` : html`) => (this.host._decorShapeDialog = { ...dialog, ...event.detail })}>`} ${dialog.kind !== 'image' ? html`
(this.host._decorShapeDialog = { ...dialog, widthCm: this.host._decorSmallCm(Number((event.target as HTMLInputElement).value)), })} />${this.host._t( this.host._imperial ? 'wallthick.unit_in' : 'wallthick.unit_cm', )}
` : nothing} ${dialog.kind === 'line' ? html`
(this.host._decorShapeDialog = { ...dialog, lengthCm: this.host._decorLargeCm(Number((event.target as HTMLInputElement).value)), })} /> ${unit}
` : dialog.kind === 'furniture' || dialog.kind === 'image' ? html`
this.furnitureSizeInput( 'w', (event.target as HTMLInputElement).value, )} /> × this.furnitureSizeInput( 'h', (event.target as HTMLInputElement).value, )} /> ${unit}
` : html`
(this.host._decorShapeDialog = { ...dialog, sizeWCm: this.host._decorLargeCm(Number((event.target as HTMLInputElement).value)), })} /> × (this.host._decorShapeDialog = { ...dialog, sizeHCm: this.host._decorLargeCm(Number((event.target as HTMLInputElement).value)), })} /> ${unit}
`} (this.host._decorShapeDialog = { ...dialog, angle: (event.target as HTMLInputElement).value, })} /> ${canFill ? html` ) => (this.host._decorShapeDialog = { ...dialog, fillColor: event.detail.color, fillOpacity: event.detail.opacity, })}>` : nothing}
`; } }