mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 04:09:17 +00:00
441 lines
18 KiB
TypeScript
441 lines
18 KiB
TypeScript
import { nothing, render, svg, type TemplateResult } from 'lit';
|
|
import { cancelHouseplanPointerMove } from './pointer-move-queue';
|
|
import { gridVisualUnits } from './grid-scale';
|
|
import type { RenderOpening } from './interaction-types';
|
|
import type { SpaceDisplay } from './logic';
|
|
import type { SpaceModel, WallEntry } from './types';
|
|
import {
|
|
wallBodyNeedsSolid, wallCmToUnits, wallEdgePathD,
|
|
wallHatchNeedsSolid, wallHatchStepUnits,
|
|
type WallEdgeBody,
|
|
} from './wall-thickness';
|
|
import { NORM_W } from './space-geometry';
|
|
|
|
interface LiveEditorState {
|
|
raf: number;
|
|
requestedRevision: number;
|
|
paintedRevision: number;
|
|
waiters: { revision: number; resolve: () => void }[];
|
|
hidden: HTMLElement[];
|
|
transparent: HTMLElement[];
|
|
dimmed: HTMLElement[];
|
|
}
|
|
|
|
interface LiveEditorHost {
|
|
isConnected: boolean;
|
|
renderRoot: ParentNode;
|
|
requestUpdate: () => void;
|
|
_mode: 'view' | 'plan' | 'devices' | 'decor';
|
|
_tool: string;
|
|
_deviceDrag: { id: string } | null;
|
|
_physicalDrag?: unknown;
|
|
_physicalRotate?: unknown;
|
|
_decorDraft?: unknown;
|
|
_decorMove?: { id: string } | null;
|
|
_dtDrag?: { id: string } | null;
|
|
_bdDrag?: unknown;
|
|
_opDrag?: unknown;
|
|
_resize?: { dragging: boolean; plan?: { roomIds: string[]; movingOpeningIds: string[] } | null };
|
|
_layout: unknown;
|
|
_dtBox: { id: string; x: number; y: number; w: number; h: number } | null;
|
|
_dtSel: { id: string; kind: string } | null;
|
|
_opMeasureView: { guide: unknown } | null;
|
|
_devices: { id: string }[];
|
|
_liveEditorPaintCount: number;
|
|
_baseVb: () => number[];
|
|
_viewOr: (viewBox: number[]) => { x: number; y: number; w: number; h: number };
|
|
_spaceDisplayForRender: () => SpaceDisplay;
|
|
_renderWallBodies: (display: unknown) => unknown;
|
|
_renderResizeMeasurements: () => unknown;
|
|
_renderOpenings: (display: unknown, onlyIds?: readonly string[]) => unknown;
|
|
_renderResizeLayer: (view: unknown, roomIds?: readonly string[]) => unknown;
|
|
_renderMarkupLayer: (viewBox: number[]) => unknown;
|
|
_renderHiddenWallDiagnosticOverlay: () => unknown;
|
|
_renderOpeningPlacementPreview: () => unknown;
|
|
_renderOpeningDimensionGuides: (measure: unknown) => unknown;
|
|
_renderOpeningCenterTick: (guide: unknown) => unknown;
|
|
_renderActiveChainInk: () => unknown;
|
|
_renderPlanSnapOverlay: () => unknown;
|
|
_renderWallThickUi: () => unknown;
|
|
_renderDecorLayer: (onlyId?: string | null) => unknown;
|
|
_renderBackdropFrame: (view: unknown) => unknown;
|
|
_renderTextFrame: (view: unknown) => unknown;
|
|
_renderLiveEditorMeasurements: (view: unknown) => unknown;
|
|
_livePos: (device: { id: string }) => { x: number; y: number };
|
|
_scenePoint: (point: number[]) => number[];
|
|
_renderProjection: string;
|
|
_spaceModel: () => SpaceModel | null;
|
|
_spaceWalls: WallEntry[];
|
|
_cellCm: number;
|
|
_gridPitch: number;
|
|
_openingsR: RenderOpening[];
|
|
_stageEl: HTMLElement | null;
|
|
_fillColors: { wall_fill: { c: string; a: number } };
|
|
}
|
|
|
|
const states = new WeakMap<object, LiveEditorState>();
|
|
|
|
const liveProperties = new Set<PropertyKey>([
|
|
'_cursorPt', '_opMeasure', '_physicalDrag', '_physicalRotate',
|
|
'_decorDraft', '_dtBox', '_dtDrag', '_bdDrag', '_furnPreviewInput', '_layout',
|
|
]);
|
|
const hoverProperties = new Set<PropertyKey>(['_cursorPt', '_opMeasure', '_furnPreviewInput']);
|
|
const gestureProperties = new Set<PropertyKey>([
|
|
'_physicalDrag', '_physicalRotate', '_decorDraft', '_dtDrag', '_bdDrag',
|
|
]);
|
|
|
|
const stateOf = (host: object): LiveEditorState => {
|
|
let state = states.get(host);
|
|
if (!state) {
|
|
state = {
|
|
raf: 0, requestedRevision: 0, paintedRevision: 0, waiters: [],
|
|
hidden: [], transparent: [], dimmed: [],
|
|
};
|
|
states.set(host, state);
|
|
}
|
|
return state;
|
|
};
|
|
|
|
const finishRevision = (state: LiveEditorState, revision: number): void => {
|
|
state.paintedRevision = Math.max(state.paintedRevision, revision);
|
|
const ready = state.waiters.filter((waiter) => waiter.revision <= state.paintedRevision);
|
|
state.waiters = state.waiters.filter((waiter) => waiter.revision > state.paintedRevision);
|
|
for (const waiter of ready) waiter.resolve();
|
|
};
|
|
|
|
const activeEditorGesture = (host: LiveEditorHost): boolean => host._mode !== 'view' && (
|
|
!!host._deviceDrag || !!host._physicalDrag || !!host._physicalRotate
|
|
|| !!host._decorDraft || !!host._decorMove || !!host._dtDrag || !!host._bdDrag
|
|
|| !!host._opDrag
|
|
|| !!host._resize?.dragging
|
|
);
|
|
|
|
/**
|
|
* ReactiveElement calls `requestUpdate(name, oldValue)` from generated state
|
|
* setters. Only pointer-owned values are diverted; selection, dialogs, toasts
|
|
* and terminal null assignments still receive a normal host update.
|
|
*/
|
|
export function routeHouseplanEditorUpdate(
|
|
value: object,
|
|
name?: PropertyKey,
|
|
oldValue?: unknown,
|
|
): boolean {
|
|
const host = value as LiveEditorHost;
|
|
if (!host?.isConnected || host._mode === 'view') return false;
|
|
const namedLive = name !== undefined && liveProperties.has(name);
|
|
if (name === '_layout' && !host._deviceDrag) return false;
|
|
if (name === '_dtBox' && !host._dtDrag) return false;
|
|
if (name !== undefined && gestureProperties.has(name)
|
|
&& oldValue == null
|
|
&& (host as unknown as Record<PropertyKey, unknown>)[name] != null
|
|
&& activeEditorGesture(host)) {
|
|
// Pointerdown may change selection/chrome once. Subsequent moves stay live.
|
|
return false;
|
|
}
|
|
if (name !== undefined && gestureProperties.has(name) && host[name] == null) return false;
|
|
if (name !== undefined && hoverProperties.has(name)
|
|
&& (host as unknown as Record<PropertyKey, unknown>)[name] == null) return false;
|
|
const route = (namedLive && (hoverProperties.has(name!) || activeEditorGesture(host)))
|
|
|| (name === undefined && activeEditorGesture(host));
|
|
if (!route) return false;
|
|
scheduleHouseplanEditor(host);
|
|
return true;
|
|
}
|
|
|
|
const hide = (state: LiveEditorState, root: ParentNode, selector: string): void => {
|
|
for (const element of root.querySelectorAll<HTMLElement>(selector)) {
|
|
if (element.closest('[data-hp-live-editor]')) continue;
|
|
if (element.style.visibility === 'hidden') continue;
|
|
element.style.visibility = 'hidden';
|
|
state.hidden.push(element);
|
|
}
|
|
};
|
|
|
|
/** Keep the captured source hittable while only its live copy stays visible. */
|
|
const makeTransparent = (state: LiveEditorState, root: ParentNode, selector: string): void => {
|
|
for (const element of root.querySelectorAll<HTMLElement>(selector)) {
|
|
if (element.closest('[data-hp-live-editor]')) continue;
|
|
if (element.style.opacity === '0') continue;
|
|
element.style.opacity = '0';
|
|
state.transparent.push(element);
|
|
}
|
|
};
|
|
|
|
const restore = (state: LiveEditorState): void => {
|
|
for (const element of state.hidden) element.style.removeProperty('visibility');
|
|
state.hidden.length = 0;
|
|
for (const element of state.transparent) element.style.removeProperty('opacity');
|
|
state.transparent.length = 0;
|
|
for (const element of state.dimmed) element.style.removeProperty('opacity');
|
|
state.dimmed.length = 0;
|
|
};
|
|
|
|
/** Resize moves paint individual wall strips; pointerup still validates the canonical union. */
|
|
const resizePreviewWalls = (
|
|
host: LiveEditorHost, display: SpaceDisplay, roomIds: readonly string[] = [],
|
|
): TemplateResult => {
|
|
if (host._renderProjection === 'iso') return svg``;
|
|
const space = host._spaceModel();
|
|
if (!space) return svg``;
|
|
const affected = new Set(roomIds);
|
|
const boundary = (space.rooms || []).filter((room) => !!room.id && affected.has(room.id))
|
|
.flatMap((room) => {
|
|
const poly = room.poly || [];
|
|
return poly.map((point: number[], index: number) => [
|
|
point, poly[(index + 1) % poly.length],
|
|
]);
|
|
});
|
|
const onSegment = (point: number[], segment: number[][]): boolean => {
|
|
const [a, b] = segment;
|
|
const dx = b[0] - a[0], dy = b[1] - a[1];
|
|
const length2 = dx * dx + dy * dy;
|
|
if (!(length2 > 0)) return false;
|
|
const t = ((point[0] - a[0]) * dx + (point[1] - a[1]) * dy) / length2;
|
|
const clamped = Math.max(0, Math.min(1, t));
|
|
return Math.hypot(point[0] - a[0] - dx * clamped, point[1] - a[1] - dy * clamped) < 1e-4;
|
|
};
|
|
const edge = (a: number[], b: number[], cm: number, key: string): WallEdgeBody | null => {
|
|
const length = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
|
if (!(length > 1e-9) || !(cm > 0)) return null;
|
|
const half = wallCmToUnits(cm, host._cellCm, host._gridPitch) / 2;
|
|
const nx = -(b[1] - a[1]) / length * half;
|
|
const ny = (b[0] - a[0]) / length * half;
|
|
return {
|
|
key, kind: 'outer', cm, a, b, depthUnits: half * 2,
|
|
quad: [[a[0] + nx, a[1] + ny], [b[0] + nx, b[1] + ny],
|
|
[b[0] - nx, b[1] - ny], [a[0] - nx, a[1] - ny]],
|
|
};
|
|
};
|
|
const edges = [
|
|
...host._spaceWalls.filter((wall) => {
|
|
const a = (wall.a || []).map((value: number) => value * NORM_W);
|
|
const b = (wall.b || []).map((value: number) => value * NORM_W);
|
|
return boundary.some((segment: number[][]) => onSegment(a, segment) && onSegment(b, segment));
|
|
}).map((wall, index) => edge(
|
|
(wall.a || []).map((value: number) => value * NORM_W),
|
|
(wall.b || []).map((value: number) => value * NORM_W),
|
|
wall.cm, wall.key || `wall-${index}`,
|
|
)),
|
|
].filter((item): item is WallEdgeBody => !!item);
|
|
const openings = host._openingsR.map((opening) => ({
|
|
x: opening.rx, y: opening.ry, angle: opening.angle, length: opening.rlen,
|
|
}));
|
|
const paths = edges.map((edge) => wallEdgePathD(edge, openings)).filter(Boolean);
|
|
if (!paths.length) return svg``;
|
|
const depth = Math.max(...edges.map((edge) => edge.depthUnits), 0);
|
|
const view = host._viewOr(host._baseVb());
|
|
const px = host._stageEl?.clientWidth && view.w ? host._stageEl.clientWidth / view.w : 1;
|
|
const solid = wallBodyNeedsSolid(depth, px)
|
|
|| wallHatchNeedsSolid(wallHatchStepUnits(host._cellCm), px);
|
|
const stroke = display?.color || '#607d8b';
|
|
const fill = host._fillColors.wall_fill;
|
|
const d = paths.join(' ');
|
|
return svg`<g class="wallbodies hp-resize-preview-walls"
|
|
style="--room-stroke:${stroke};--wall-fill:${fill.c};--wall-fill-op:${fill.a}">
|
|
<path class="wallbody-fill" data-component="preview" d=${d}
|
|
fill=${fill.c} fill-opacity=${fill.a} fill-rule="nonzero" stroke="none"></path>
|
|
<path class="wallbody ${solid ? 'solid' : ''}" data-hp="wall"
|
|
data-id="preview" data-kind="preview" d=${d}
|
|
fill=${solid ? 'none' : 'url(#hp-wall-hatch)'} fill-rule="nonzero"
|
|
stroke=${stroke} stroke-width=${gridVisualUnits(0.6, host._cellCm)}></path>
|
|
</g>`;
|
|
};
|
|
|
|
const planTemplate = (host: LiveEditorHost): TemplateResult => {
|
|
const vb = host._baseVb();
|
|
const view = host._viewOr(vb);
|
|
const measure = host._opMeasureView;
|
|
if (host._resize?.dragging) {
|
|
const display = host._spaceDisplayForRender();
|
|
const plan = host._resize.plan;
|
|
return svg`<g class="hp-live-resize" aria-hidden="true" pointer-events="none">
|
|
${resizePreviewWalls(host, display, plan?.roomIds)}
|
|
${host._renderResizeMeasurements()}
|
|
${host._renderOpenings(display, plan?.movingOpeningIds)}
|
|
${host._renderResizeLayer(view, plan?.roomIds)}
|
|
</g>`;
|
|
}
|
|
const display = host._spaceDisplayForRender();
|
|
return svg`<g class="hp-live-plan" aria-hidden="true" pointer-events="none">
|
|
${host._opDrag ? host._renderWallBodies(display) : nothing}
|
|
${host._renderMarkupLayer(vb)}
|
|
${host._renderHiddenWallDiagnosticOverlay()}
|
|
${host._renderOpeningPlacementPreview()}
|
|
${measure ? host._renderOpeningDimensionGuides(measure) : nothing}
|
|
${measure?.guide ? host._renderOpeningCenterTick(measure.guide) : nothing}
|
|
${host._renderActiveChainInk()}
|
|
${host._tool === 'draw' ? nothing : host._renderPlanSnapOverlay()}
|
|
${host._renderWallThickUi()}
|
|
${host._opDrag ? host._renderOpenings(display) : nothing}
|
|
</g>`;
|
|
};
|
|
|
|
const editorTemplate = (host: LiveEditorHost): TemplateResult | typeof nothing => {
|
|
if (host._mode === 'plan') return planTemplate(host);
|
|
if (host._mode === 'decor') {
|
|
const view = host._viewOr(host._baseVb());
|
|
const activeId = host._dtDrag?.id || host._decorMove?.id || null;
|
|
return svg`<g class="hp-live-decor" aria-hidden="true" pointer-events="none">
|
|
${host._renderDecorLayer(activeId)}
|
|
${host._renderBackdropFrame(view)}
|
|
${host._renderTextFrame(view)}
|
|
</g>`;
|
|
}
|
|
return nothing;
|
|
};
|
|
|
|
const paintDevice = (host: LiveEditorHost, root: ParentNode): void => {
|
|
const drag = host._deviceDrag;
|
|
if (!drag) return;
|
|
const device = host._devices.find((candidate) => candidate.id === drag.id);
|
|
const element = [...root.querySelectorAll<HTMLElement>('[data-hp="device"]')]
|
|
.find((candidate) => candidate.dataset.id === drag.id);
|
|
if (!device || !element) return;
|
|
const position = host._livePos(device);
|
|
const point = host._scenePoint([position.x, position.y]);
|
|
const view = host._viewOr(host._baseVb());
|
|
element.style.left = `${((point[0] - view.x) / view.w) * 100}%`;
|
|
element.style.top = `${((point[1] - view.y) / view.h) * 100}%`;
|
|
};
|
|
|
|
const makeDecorShapeTransparent = (
|
|
state: LiveEditorState, root: ParentNode, id: string,
|
|
): void => {
|
|
for (const element of root.querySelectorAll<HTMLElement>('.decorlayer [data-hp="decor"]')) {
|
|
if (element.closest('[data-hp-live-editor]') || element.dataset.id !== id) continue;
|
|
if (element.style.opacity === '0') continue;
|
|
element.style.opacity = '0';
|
|
state.transparent.push(element);
|
|
}
|
|
};
|
|
|
|
const makeMovingOpeningsTransparent = (
|
|
state: LiveEditorState, root: ParentNode, ids: readonly string[],
|
|
): void => {
|
|
const moving = new Set(ids);
|
|
for (const element of root.querySelectorAll<HTMLElement>('.openinglayer [data-hp="opening"]')) {
|
|
if (!element.closest('[data-hp-live-editor]') && moving.has(element.dataset.id || '')) {
|
|
element.style.opacity = '0';
|
|
state.transparent.push(element);
|
|
}
|
|
}
|
|
};
|
|
|
|
const dim = (state: LiveEditorState, root: ParentNode, selector: string): void => {
|
|
for (const element of root.querySelectorAll<HTMLElement>(selector)) {
|
|
if (element.closest('[data-hp-live-editor]')) continue;
|
|
element.style.opacity = '0.35';
|
|
state.dimmed.push(element);
|
|
}
|
|
};
|
|
|
|
/** One editor-only render root per card; the settled scene remains untouched. */
|
|
export function paintHouseplanEditor(value: object): void {
|
|
const host = value as LiveEditorHost;
|
|
const root = host.renderRoot;
|
|
if (!root) return;
|
|
const state = stateOf(host);
|
|
restore(state);
|
|
paintDevice(host, root);
|
|
const target = root.querySelector<SVGElement>('[data-hp-live-editor]');
|
|
if (!target) return;
|
|
if (host._mode === 'plan') {
|
|
makeTransparent(state, root, '.hp-editor-only-layer:not(.hp-plan-snap-layer)');
|
|
if (host._opDrag) hide(state, root, '.wallbodies');
|
|
if (host._resize?.dragging) {
|
|
dim(state, root, '.wallbodies');
|
|
makeTransparent(state, root, '.resize-layer');
|
|
makeMovingOpeningsTransparent(state, root, host._resize.plan?.movingOpeningIds || []);
|
|
}
|
|
if (host._opDrag) makeTransparent(state, root, '.openinglayer');
|
|
} else if (host._mode === 'decor') {
|
|
const activeId = host._dtDrag?.id || host._decorMove?.id;
|
|
if (activeId) makeDecorShapeTransparent(state, root, activeId);
|
|
makeTransparent(state, root, '.dtframe, .backdropframe');
|
|
}
|
|
render(editorTemplate(host), target);
|
|
const htmlTarget = root.querySelector<HTMLElement>('[data-hp-live-editor-html]');
|
|
if (htmlTarget) {
|
|
const view = host._viewOr(host._baseVb());
|
|
render(host._renderLiveEditorMeasurements(view), htmlTarget);
|
|
}
|
|
host._liveEditorPaintCount = (host._liveEditorPaintCount || 0) + 1;
|
|
}
|
|
|
|
export function scheduleHouseplanEditor(value: object): void {
|
|
const state = stateOf(value);
|
|
state.requestedRevision++;
|
|
if (state.raf || typeof requestAnimationFrame !== 'function') return;
|
|
state.raf = requestAnimationFrame(() => {
|
|
state.raf = 0;
|
|
const revision = state.requestedRevision;
|
|
paintHouseplanEditor(value);
|
|
finishRevision(state, revision);
|
|
});
|
|
}
|
|
|
|
/** Await the latest live-editor frame which was scheduled before this call. */
|
|
export function whenHouseplanEditorSettled(value: object): Promise<void> {
|
|
const state = states.get(value);
|
|
if (!state || !state.raf || state.paintedRevision >= state.requestedRevision)
|
|
return Promise.resolve();
|
|
const revision = state.requestedRevision;
|
|
return new Promise((resolve) => state.waiters.push({ revision, resolve }));
|
|
}
|
|
|
|
/** Only text needs a DOM measurement; every box-like item owns its config box. */
|
|
export function measureHouseplanDecorText(value: object): void {
|
|
const host = value as LiveEditorHost;
|
|
const shape = host._dtSel;
|
|
if (!shape) {
|
|
if (host._dtBox) { host._dtBox = null; host.requestUpdate(); }
|
|
return;
|
|
}
|
|
if (shape.kind !== 'text') return;
|
|
const element = host.renderRoot.querySelector(
|
|
`text.dtext[data-id="${shape.id}"]`,
|
|
) as SVGGraphicsElement | null;
|
|
if (!element || typeof element.getBBox !== 'function') return;
|
|
let measured: DOMRect;
|
|
try { measured = element.getBBox(); } catch { return; }
|
|
if (!measured || (!measured.width && !measured.height)) return;
|
|
const box = {
|
|
id: shape.id, x: measured.x, y: measured.y,
|
|
w: measured.width, h: measured.height,
|
|
};
|
|
const current = host._dtBox;
|
|
if (current?.id === box.id && Math.abs(current.x - box.x) < 0.01
|
|
&& Math.abs(current.y - box.y) < 0.01 && Math.abs(current.w - box.w) < 0.01
|
|
&& Math.abs(current.h - box.h) < 0.01) return;
|
|
host._dtBox = box;
|
|
host.requestUpdate();
|
|
}
|
|
|
|
export function commitHouseplanEditor(value: object): void {
|
|
const host = value as LiveEditorHost;
|
|
const state = stateOf(value);
|
|
if (state.raf && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(state.raf);
|
|
state.raf = 0;
|
|
restore(state);
|
|
const target = (host.renderRoot as ParentNode | undefined)
|
|
?.querySelector<SVGElement>('[data-hp-live-editor]');
|
|
if (target) render(nothing, target);
|
|
const htmlTarget = (host.renderRoot as ParentNode | undefined)
|
|
?.querySelector<HTMLElement>('[data-hp-live-editor-html]');
|
|
if (htmlTarget) {
|
|
const view = host._viewOr(host._baseVb());
|
|
render(host._renderLiveEditorMeasurements(view), htmlTarget);
|
|
}
|
|
finishRevision(state, state.requestedRevision);
|
|
}
|
|
|
|
export function disposeHouseplanEditor(host: object): void {
|
|
cancelHouseplanPointerMove(host);
|
|
const state = states.get(host);
|
|
if (!state) return;
|
|
if (state.raf && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(state.raf);
|
|
restore(state);
|
|
finishRevision(state, state.requestedRevision);
|
|
states.delete(host);
|
|
}
|