feat: detach wall chain on Escape

Issue: #294
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-24 18:16:46 +00:00
committed by claude[bot]
parent 578eb5ebd3
commit 953f6756bc
10 changed files with 141 additions and 29 deletions
File diff suppressed because one or more lines are too long
+97 -11
View File
@@ -9,6 +9,23 @@ const result = await page.evaluate(async () => {
const root = () => card.shadowRoot || card.renderRoot;
const update = async () => { card.requestUpdate(); await card.updateComplete; };
const space = () => card._serverCfg.spaces[0];
const keyDown = async (key, init = {}) => {
window.dispatchEvent(new KeyboardEvent('keydown', {
key, bubbles: true, cancelable: true, ...init,
}));
await update();
};
const clickStage = async (x, y) => {
const stage = root().querySelector('.stage');
const rect = stage.getBoundingClientRect();
const view = card._viewOr(card._baseVb());
stage.dispatchEvent(new MouseEvent('click', {
clientX: rect.left + ((x - view.x) / view.w) * rect.width,
clientY: rect.top + ((y - view.y) / view.h) * rect.height,
bubbles: true, cancelable: true,
}));
await update();
};
const resetGeometry = async (rooms = []) => {
const current = space();
current.rooms = JSON.parse(JSON.stringify(rooms));
@@ -24,9 +41,14 @@ const result = await page.evaluate(async () => {
card._wallFaceBatch = null;
card._roomDialog = false;
card._tool = 'draw';
card._cursorPt = null;
card._suppressClick = false;
card._geometryHistory.clear();
delete card._resumeDraftBySpace[card._space];
card._clearPlanSnapHover();
await update();
};
const draw = async (points) => {
const draw = async (points, cms = []) => {
card._tool = 'draw';
card._path = [[...points[0]]];
card._activeDraftId = null;
@@ -34,7 +56,7 @@ const result = await page.evaluate(async () => {
for (let i = 1; i < points.length; i++) {
const before = card._path.map((point) => [...point]);
card._path = [...card._path, [...points[i]]];
card._draftSegmentCms = [...card._draftSegmentCms, card._drawWallCm];
card._draftSegmentCms = [...card._draftSegmentCms, cms[i - 1] ?? card._drawWallCm];
card._persistActiveDraftSegment();
card._offerWallFaces(before);
await update();
@@ -74,6 +96,64 @@ const result = await page.evaluate(async () => {
&& space().partitions.every((partition, index) => partition.cm === cms[index]);
out.finishedChainDoesNotResume = !card._activeDraftId && card._path.length === 0;
// #294: Escape owns the registered window keyboard path, finishes exactly
// like a tool change and leaves Walls armed for an independent next chain.
await resetGeometry();
await draw([[100, 100], [300, 100], [300, 250]], [12, 23]);
await keyDown('Escape');
const escapedPartitions = space().partitions || [];
out.escapeFinishesWithoutDeletingSegments = card._tool === 'draw'
&& card._path.length === 0 && !card._activeDraftId && !space().room_drafts
&& !card._resumeDraftBySpace[card._space]
&& escapedPartitions.length === 2
&& escapedPartitions.some((part) => part.cm === 12)
&& escapedPartitions.some((part) => part.cm === 23)
&& card._geometryHistory.undoName === card._t('history.wall_chain_finish')
&& !root().querySelector('.active-axis') && !root().querySelector('.active-vertex');
const escapedGeometry = JSON.stringify(space());
const escapedHistory = card._geometryHistory.undoName;
await keyDown('Escape');
out.repeatedEscapeAfterFinishIsNoop = JSON.stringify(space()) === escapedGeometry
&& card._geometryHistory.undoName === escapedHistory
&& card._tool === 'draw' && card._path.length === 0;
await clickStage(500, 500);
out.nextClickStartsIndependentChain = card._path.length === 1
&& card._path[0][0] === 500 && card._path[0][1] === 500
&& !space().room_drafts && (space().partitions || []).length === 2;
await clickStage(700, 500);
const independentDraft = space().room_drafts?.[0];
out.secondClickCreatesOnlyIndependentSegment = card._path.length === 2
&& independentDraft?.points?.length === 2
&& independentDraft.points[0][0] === 0.5 && independentDraft.points[0][1] === 0.5
&& independentDraft.points[1][0] === 0.7 && independentDraft.points[1][1] === 0.5
&& (space().partitions || []).length === 2;
await resetGeometry();
const singlePointGeometry = JSON.stringify(space());
const singlePointHistory = card._geometryHistory.undoName;
await clickStage(200, 200);
const onePointWasArmed = card._path.length === 1 && !space().room_drafts;
await keyDown('Escape');
out.escapeClearsOnlyTransientFirstPoint = onePointWasArmed
&& card._tool === 'draw' && card._path.length === 0 && !card._activeDraftId
&& JSON.stringify(space()) === singlePointGeometry
&& card._geometryHistory.undoName === singlePointHistory;
await resetGeometry();
await draw([[100, 100], [300, 100], [300, 250]], [12, 23]);
const rejectedPath = JSON.stringify(card._path);
const rejectedDraft = JSON.stringify(space().room_drafts);
const rejectedHistory = card._geometryHistory.undoName;
const finishWallChain = card._finishWallChain;
card._finishWallChain = () => false;
await keyDown('Escape');
card._finishWallChain = finishWallChain;
out.rejectedFinishKeepsActiveDraft = JSON.stringify(card._path) === rejectedPath
&& JSON.stringify(space().room_drafts) === rejectedDraft
&& card._geometryHistory.undoName === rejectedHistory
&& card._activeDraftId === space().room_drafts?.[0]?.id
&& !(space().partitions?.length);
await resetGeometry();
await draw([[100, 100], [300, 100], [300, 300], [100, 300], [100, 100]]);
out.closedFaceOpensDialog = card._roomDialog && card._wallFaceBatch?.candidates.length === 1;
@@ -93,19 +173,25 @@ const result = await page.evaluate(async () => {
out.cancelKeepsWholeTerminalDraft = !card._roomDialog && !card._wallFaceBatch
&& JSON.stringify(space().room_drafts) === draftBeforeCancel
&& card._path.length === 5;
card._onKey(new KeyboardEvent('keydown', {
key: 'z', code: 'KeyZ', ctrlKey: true, bubbles: true,
}));
await update();
out.escapeUndoContractStillRemovesLastSegment = space().room_drafts?.[0]?.segments.length === 3
await keyDown('z', { code: 'KeyZ', ctrlKey: true });
out.ctrlZContractStillRemovesLastSegment = space().room_drafts?.[0]?.segments.length === 3
&& card._path.length === 4;
await resetGeometry();
await draw([[500, 100], [700, 100], [700, 300], [500, 300], [500, 100]]);
const dialogDraft = JSON.stringify(space().room_drafts);
await keyDown('Escape');
out.firstEscapeOnlyCancelsRoomDialog = !card._roomDialog && !card._wallFaceBatch
&& JSON.stringify(space().room_drafts) === dialogDraft
&& card._path.length === 5 && !(space().partitions?.length);
await keyDown('Escape');
out.secondEscapeFinishesRestoredDraft = !space().room_drafts
&& card._path.length === 0 && !card._activeDraftId
&& space().partitions?.length === 4 && card._tool === 'draw';
await resetGeometry();
await draw([[100, 450], [300, 450], [300, 650], [100, 650], [100, 450]]);
card._onKey(new KeyboardEvent('keydown', {
key: 'z', code: 'KeyZ', ctrlKey: true, bubbles: true,
}));
await update();
await keyDown('z', { code: 'KeyZ', ctrlKey: true });
out.ctrlZInQueueRemovesTerminalPoint = !card._roomDialog && !card._wallFaceBatch
&& card._path.length === 4 && space().room_drafts?.[0]?.segments.length === 3;
+3 -3
View File
File diff suppressed because one or more lines are too long
+6
View File
@@ -2,6 +2,12 @@
## Unreleased
- While drawing Walls, `Esc` now finishes all accepted segments as independent
walls and releases the last point without deleting geometry or leaving the
tool. The next click starts a new chain; `Ctrl/Cmd+Z` remains the shortcut
that removes the last point and segment
([#294](https://github.com/Matysh/houseplan-card/issues/294)).
## v1.67.0-rc.1 — 2026-08-24
- Republished the reviewed beta.10 candidate under an RC identifier so HACS
+6
View File
@@ -8,6 +8,12 @@
## Не выпущено
- При рисовании стен `Esc` теперь завершает все принятые отрезки как
независимые стены и отцепляется от последней точки, не удаляя геометрию и не
покидая инструмент. Следующий клик начинает новую цепочку, а `Ctrl/Cmd+Z`
по-прежнему удаляет последнюю точку и отрезок
([#294](https://github.com/Matysh/houseplan-card/issues/294)).
## v1.67.0-rc.1 — 2026-08-24
- Проверенный кандидат beta.10 переопубликован с RC-идентификатором, чтобы HACS
+10 -3
View File
@@ -204,7 +204,7 @@ objects; each space keeps its local View viewport.
| Zoom and pan | Wheel; drag empty space; `−`/`+` | Pinch; drag; double-tap resets kiosk | Available but precision is not guaranteed | — |
| Change space | Click a tab | Tap; kiosk swipe at 1:1 | Tap a tab | — |
| Device | Click/double-click per mode | Tap; safe actions equal desktop | Drag/properties are best effort | `Esc` closes the top surface |
| Draw or precise drag | Full contract | Not applicable | Best effort; use desktop for Resize and exact nodes | `Shift` changes magnet/angle; `Esc` cancels the operation |
| Walls drawing or precise drag | Full contract | Not applicable | Best effort; use desktop for Resize and exact nodes | `Shift` changes magnet/angle; `Esc` finishes a Walls chain or cancels the current precise drag |
| Editor history | Undo/Redo controls | Not applicable | Controls may work; no gesture guarantee | `Ctrl/Cmd+Z`, `Ctrl/Cmd+Shift+Z`, `Ctrl+Y` |
| Kiosk sizing | — | Hold empty space for 3 seconds | — | — |
@@ -213,8 +213,10 @@ Any manual kiosk operation pauses auto-cycle for 60 seconds.
### Cancel and undo
- `Esc` cancels an unfinished path, current drag/resize/rotation, or the top
dialog without undoing an already committed action.
- `Esc` finishes an active Walls chain without deleting its accepted segments.
In Split and other tools it cancels the unfinished path, current
drag/resize/rotation, or the top dialog without undoing an already committed
action.
- `Ctrl/Cmd+Z` undoes an editor command; redo is `Ctrl/Cmd+Shift+Z` or `Ctrl+Y`.
- Undo/Redo stores up to 50 named commands for Plan and Background.
@@ -298,6 +300,11 @@ House Plan offers them from smallest to largest. Save creates that room, Keep
as walls rejects only that candidate, and Cancel restores the whole draft with
no partial rooms.
While drawing an open chain, `Esc` finishes all accepted segments as ordinary
independent walls and keeps **Walls** selected; the next click starts a new
chain. `Ctrl/Cmd+Z` instead removes the last accepted point and segment. Pan,
pinch and `pointercancel` neither finish the chain nor add geometry.
Existing segment endpoints and lines appear above walls while drawing. An
endpoint grows when the next click will join it. A point on a line shows where
the click will create a valid junction.
+10 -6
View File
@@ -220,7 +220,7 @@ desktop: для точного рисования, Resize, модификато
| Масштаб и панорама | Колесо; drag пустого места; кнопки `−`/`+` | Щипок; drag; двойной тап сбрасывает киоск | Работает, но точность не гарантируется | — |
| Переключение пространства | Клик по вкладке | Клик; в киоске свайп при масштабе 1:1 | Клик по вкладке | — |
| Устройство | Клик/двойной клик по правилам режима | Tap; безопасные действия как на desktop | Drag и свойства — best effort | `Esc` закрывает верхнюю поверхность |
| Рисование и точный drag | Полный контракт | Не применяется | Best effort; для Resize и точных точек используйте мышь | `Shift` меняет магнит/угол; `Esc` отменяет операцию |
| Рисование стен и точный drag | Полный контракт | Не применяется | Best effort; для Resize и точных точек используйте мышь | `Shift` меняет магнит/угол; `Esc` завершает цепочку стен или отменяет текущий точный drag |
| История редактора | Кнопки Undo/Redo | Не применяется | Кнопки могут работать, жест не гарантирован | `Ctrl/Cmd+Z`, `Ctrl/Cmd+Shift+Z`, `Ctrl+Y` |
| Киоск-размеры | — | Удерживать пустое место 3 секунды | — | — |
@@ -238,8 +238,9 @@ desktop: для точного рисования, Resize, модификато
| Контекст | `Esc` | `Ctrl+Z` / `Cmd+Z` | `Ctrl+Shift+Z` / `Ctrl+Y` |
|---|---|---|---|
| Незавершённый Draw/Split | Убирает последнюю точку или выходит из инструмента | Сначала убирает незавершённую точку | — |
| Любой инструмент редактора плана | Отменяет текущий жест/выбор | Отменяет последнюю именованную геометрическую операцию | Повторяет отменённую операцию |
| Активная цепочка «Стены» | Завершает принятые отрезки как независимые стены, оставляя инструмент активным | Убирает последнюю принятую точку и отрезок | — |
| Незавершённый Split | Убирает последнюю точку или выходит из инструмента | Сначала убирает незавершённую точку | — |
| Любой другой инструмент редактора плана | Отменяет текущий жест/выбор | Отменяет последнюю именованную геометрическую операцию | Повторяет отменённую операцию |
| Подложка | Отменяет незавершённое рисование или возвращает состояние до начала активного move/resize/rotate | Отменяет последнюю именованную операцию декора или картинки | Повторяет отменённую операцию |
| Текстовое поле | Закрывает верхний диалог | Обычная отмена текста браузером | Обычный повтор текста браузером |
@@ -409,9 +410,12 @@ T-соединение входит в проходящую стену без в
Отмена ничего не записывает, подтверждённое исправление можно отменить обычным
Undo оптимизации.
Каждый законченный отрезок цепочки сохраняется сразу. Esc/Ctrl+Z удаляет
последнюю точку. Pan, pinch и `pointercancel` ничего не завершают и не добавляют.
После перезагрузки сохранённый draft можно продолжить кликом по его концу.
Каждый законченный отрезок цепочки сохраняется сразу. `Esc` завершает принятые
отрезки как независимые стены, оставляет инструмент **Стены** активным и
отцепляется от последней точки; следующий клик начинает новую цепочку.
`Ctrl/Cmd+Z` вместо этого удаляет последнюю точку и отрезок. Pan, pinch и
`pointercancel` ничего не завершают и не добавляют. После перезагрузки
сохранённый draft можно продолжить кликом по его концу.
### Связь с HA-зоной
+4 -1
View File
@@ -2627,7 +2627,10 @@ class HouseplanCard extends LitElement {
}
if (this._tool === 'draw' && this._path.length) {
e.preventDefault();
this._undoPoint();
// Escape releases the active wall chain without making it destructive:
// Ctrl/Cmd+Z remains the one-point undo path (#294). Reuse the exact
// finish transaction used by a tool change, but keep `draw` selected.
this._finishWallChain();
return;
}
if (this._physicalSel) {
+1 -1
View File
@@ -158,7 +158,7 @@
"confirm.delete_partition_openings_body": "This wall contains {count} opening(s). They will be deleted together.",
"confirm.delete_partition_openings_item": "• {type}, {length}",
"markup.delete": "Delete",
"markup.hint_points": "points: {n} · Shift — 45° steps · Esc/Ctrl+Z — undo a dot · closing an area offers a room",
"markup.hint_points": "points: {n} · Shift — 45° steps · Esc — finish chain · Ctrl+Z — undo a point · closing an area offers a room",
"markup.hint_start": "click a grid dot to start a wall chain",
"tip.lqi": "average zigbee signal:",
"tip.area": "area: {value}",
+1 -1
View File
@@ -158,7 +158,7 @@
"confirm.delete_partition_openings_body": "В стене есть проёмы: {count}. Они будут удалены вместе со стеной.",
"confirm.delete_partition_openings_item": "• {type}, {length}",
"markup.delete": "Удалить",
"markup.hint_points": "точек: {n} · Shift — шаг 45° · Esc/Ctrl+Z — убрать точку · при замыкании будет предложена комната",
"markup.hint_points": "точек: {n} · Shift — шаг 45° · Esc — завершить цепочку · Ctrl+Z — убрать точку · при замыкании будет предложена комната",
"markup.hint_start": "кликните точку сетки, чтобы начать цепочку стен",
"tip.lqi": "средний сигнал zigbee:",
"tip.area": "площадь: {value}",