Neu gezeichnete Elemente treten der aktiven Gruppe bei; Fix: m/s/d bei Mehrfachauswahl
Zeichnet man ein neues Element, während man eine Gruppe betreten hat (Isolation), wird es automatisch Mitglied dieser (innersten) Gruppe — man zeichnet ja sichtlich "in" ihr. Vergleicht vor/nach jedem Commit über alle Elementarten auf neu hinzugekommene IDs, additiv zur eigentlichen Mutation. Separater Fix: Bewegen/Kopieren/Spiegeln/Drehen/Feld (m/s/d-Kürzel und die gleichnamigen Befehle) funktionierten nicht, sobald mehr als ein 2D-Element gewählt war — TransformSelection kannte nur ein einzelnes `drawingId`, das bei Mehrfachauswahl `null` ist. Neues `drawingIds`-Feld (Union mit `drawingId`, dedupliziert) durchgereicht von allen Aufrufstellen (Move/ Copy/Mirror/Array-Befehle, Tastatur-Kürzel-Pfad).
This commit is contained in:
+14
-2
@@ -115,7 +115,7 @@ import { useGripEditing } from "./state/useGripEditing";
|
||||
import { usePanelHostBase } from "./state/usePanelHostBase";
|
||||
import { useDockDragState } from "./state/useDockDragState";
|
||||
import { useMouseSelectionHandlers } from "./state/useMouseSelectionHandlers";
|
||||
import { containingGroupId, EMPTY_MEMBERSHIP } from "./state/groupActions";
|
||||
import { addNewElementsToActiveGroup, containingGroupId, EMPTY_MEMBERSHIP } from "./state/groupActions";
|
||||
import type { GroupMembership } from "./state/groupActions";
|
||||
import { useProjectFileActions } from "./state/useProjectFileActions";
|
||||
import { useProjectActions } from "./state/useProjectActions";
|
||||
@@ -621,6 +621,7 @@ export default function App() {
|
||||
const transformSel: TransformSelection = {
|
||||
wallIds: selectedWallIds,
|
||||
drawingId: selectedDrawingId,
|
||||
drawingIds: selectedDrawingIds,
|
||||
extrudedSolidId: selectedExtrudedSolidId,
|
||||
columnId: selectedColumnId,
|
||||
};
|
||||
@@ -745,6 +746,7 @@ export default function App() {
|
||||
selectedDrawingIds,
|
||||
selectedExtrudedSolidId,
|
||||
selectedColumnId,
|
||||
groupPath,
|
||||
setProject,
|
||||
setDraft,
|
||||
});
|
||||
@@ -761,6 +763,7 @@ export default function App() {
|
||||
selectedDrawingIds,
|
||||
selectedExtrudedSolidId,
|
||||
selectedColumnId,
|
||||
groupPath,
|
||||
setProject,
|
||||
setDraft,
|
||||
};
|
||||
@@ -808,7 +811,15 @@ export default function App() {
|
||||
},
|
||||
pxPerMeter: () => planViewRef.current?.pxPerMeter() ?? 90,
|
||||
setDraft: (d) => cmdLatest.current.setDraft(d),
|
||||
commit: (mutate) => cmdLatest.current.setProject(mutate),
|
||||
// Zeichnet man WÄHREND man eine Gruppe betreten hat (Isolation), wird
|
||||
// das neue Element automatisch Mitglied dieser Gruppe (Nutzer-Wunsch:
|
||||
// „wenn man etwas neu zeichnet ... zeichnet man in der Gruppe") — s.
|
||||
// state/groupActions.ts::addNewElementsToActiveGroup (Diff vor/nach
|
||||
// Commit über alle Elementarten, additiv zur eigentlichen Mutation).
|
||||
commit: (mutate) =>
|
||||
cmdLatest.current.setProject((p) =>
|
||||
addNewElementsToActiveGroup(p, mutate(p), cmdLatest.current.groupPath),
|
||||
),
|
||||
isFloor: () => cmdLatest.current.activeLevel.kind === "floor",
|
||||
// Frisch über `text`/`textbox` erzeugtes Element sofort inline editieren
|
||||
// (kein Lookup nötig — der Commit hat es bereits angehängt, s. engine.ts
|
||||
@@ -1270,6 +1281,7 @@ export default function App() {
|
||||
setActiveTransform,
|
||||
selectedWallIds,
|
||||
selectedDrawingId,
|
||||
selectedDrawingIds,
|
||||
selectedExtrudedSolidId,
|
||||
selectedColumnId,
|
||||
engine,
|
||||
|
||||
@@ -49,12 +49,19 @@ function targetFromFields(base: Vec2, locks: Record<string, number>, cursor: Vec
|
||||
}
|
||||
|
||||
function hasSelection(sel: CommandSelection): boolean {
|
||||
return sel.wallIds.length > 0 || sel.drawingId !== null || !!sel.extrudedSolidId || !!sel.columnId;
|
||||
return (
|
||||
sel.wallIds.length > 0 ||
|
||||
sel.drawingId !== null ||
|
||||
(sel.drawingIds?.length ?? 0) > 0 ||
|
||||
!!sel.extrudedSolidId ||
|
||||
!!sel.columnId
|
||||
);
|
||||
}
|
||||
function toTransformSel(sel: CommandSelection): TransformSelection {
|
||||
return {
|
||||
wallIds: sel.wallIds,
|
||||
drawingId: sel.drawingId,
|
||||
drawingIds: sel.drawingIds,
|
||||
extrudedSolidId: sel.extrudedSolidId,
|
||||
columnId: sel.columnId,
|
||||
};
|
||||
|
||||
@@ -45,6 +45,7 @@ function hasSelection(sel: CommandSelection): boolean {
|
||||
return (
|
||||
sel.wallIds.length > 0 ||
|
||||
sel.drawingId !== null ||
|
||||
(sel.drawingIds?.length ?? 0) > 0 ||
|
||||
!!sel.extrudedSolidId ||
|
||||
!!sel.columnId
|
||||
);
|
||||
@@ -53,6 +54,7 @@ function toTransformSel(sel: CommandSelection): TransformSelection {
|
||||
return {
|
||||
wallIds: sel.wallIds,
|
||||
drawingId: sel.drawingId,
|
||||
drawingIds: sel.drawingIds,
|
||||
extrudedSolidId: sel.extrudedSolidId,
|
||||
columnId: sel.columnId,
|
||||
};
|
||||
|
||||
@@ -35,6 +35,7 @@ function hasSelection(sel: CommandSelection): boolean {
|
||||
return (
|
||||
sel.wallIds.length > 0 ||
|
||||
sel.drawingId !== null ||
|
||||
(sel.drawingIds?.length ?? 0) > 0 ||
|
||||
!!sel.extrudedSolidId ||
|
||||
!!sel.columnId
|
||||
);
|
||||
@@ -45,6 +46,7 @@ function toTransformSel(sel: CommandSelection): TransformSelection {
|
||||
return {
|
||||
wallIds: sel.wallIds,
|
||||
drawingId: sel.drawingId,
|
||||
drawingIds: sel.drawingIds,
|
||||
extrudedSolidId: sel.extrudedSolidId,
|
||||
columnId: sel.columnId,
|
||||
};
|
||||
|
||||
@@ -52,6 +52,7 @@ function hasSelection(sel: CommandSelection): boolean {
|
||||
return (
|
||||
sel.wallIds.length > 0 ||
|
||||
sel.drawingId !== null ||
|
||||
(sel.drawingIds?.length ?? 0) > 0 ||
|
||||
!!sel.extrudedSolidId ||
|
||||
!!sel.columnId
|
||||
);
|
||||
@@ -62,6 +63,7 @@ function toTransformSel(sel: CommandSelection): TransformSelection {
|
||||
return {
|
||||
wallIds: sel.wallIds,
|
||||
drawingId: sel.drawingId,
|
||||
drawingIds: sel.drawingIds,
|
||||
extrudedSolidId: sel.extrudedSolidId,
|
||||
columnId: sel.columnId,
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
addNewElementsToActiveGroup,
|
||||
applyGroupDimming,
|
||||
containingGroupId,
|
||||
expandToGroups,
|
||||
@@ -185,6 +186,51 @@ describe("applyGroupDimming", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("addNewElementsToActiveGroup", () => {
|
||||
const g1: Group = { id: "g1", wallIds: ["w1"], drawingIds: [], ceilingIds: [], roomIds: [], stairIds: [], columnIds: [], roofIds: [], openingIds: [], groupIds: [] };
|
||||
const line = (id: string) =>
|
||||
({ id, type: "drawing2d", levelId: "eg", categoryCode: "20", geom: { shape: "line", a: { x: 0, y: 0 }, b: { x: 1, y: 0 } } }) as Project["drawings2d"][number];
|
||||
|
||||
it("neu gezeichnetes Element wird Mitglied der aktiven (innersten) Gruppe", () => {
|
||||
const before = project([g1]);
|
||||
const after: Project = { ...before, drawings2d: [line("d1")] };
|
||||
const result = addNewElementsToActiveGroup(before, after, ["g1"]);
|
||||
const g = result.groups!.find((x) => x.id === "g1")!;
|
||||
expect(g.drawingIds).toEqual(["d1"]);
|
||||
expect(g.wallIds).toEqual(["w1"]); // bestehende Mitgliedschaft bleibt
|
||||
});
|
||||
|
||||
it("ohne aktive Gruppe (groupPath leer) bleibt das Projekt unverändert", () => {
|
||||
const before = project([g1]);
|
||||
const after: Project = { ...before, drawings2d: [line("d1")] };
|
||||
const result = addNewElementsToActiveGroup(before, after, []);
|
||||
expect(result).toBe(after); // unverändert (Referenzgleichheit)
|
||||
});
|
||||
|
||||
it("keine Änderung (before === after) ist ein No-op", () => {
|
||||
const p = project([g1]);
|
||||
expect(addNewElementsToActiveGroup(p, p, ["g1"])).toBe(p);
|
||||
});
|
||||
|
||||
it("mehrere neue Elemente auf einmal (z. B. Feld/Array) werden alle Mitglied", () => {
|
||||
const before = project([g1]);
|
||||
const after: Project = { ...before, drawings2d: [line("d1"), line("d2"), line("d3")] };
|
||||
const result = addNewElementsToActiveGroup(before, after, ["g1"]);
|
||||
const g = result.groups!.find((x) => x.id === "g1")!;
|
||||
expect(g.drawingIds.sort()).toEqual(["d1", "d2", "d3"]);
|
||||
});
|
||||
|
||||
it("verschachtelt: neues Element landet in der INNERSTEN (aktiven) Gruppe, nicht der äusseren", () => {
|
||||
const inner: Group = { id: "g1", wallIds: [], drawingIds: [], ceilingIds: [], roomIds: [], stairIds: [], columnIds: [], roofIds: [], openingIds: [], groupIds: [] };
|
||||
const outer: Group = { id: "g2", wallIds: [], drawingIds: [], ceilingIds: [], roomIds: [], stairIds: [], columnIds: [], roofIds: [], openingIds: [], groupIds: ["g1"] };
|
||||
const before = project([inner, outer]);
|
||||
const after: Project = { ...before, drawings2d: [line("d1")] };
|
||||
const result = addNewElementsToActiveGroup(before, after, ["g2", "g1"]);
|
||||
expect(result.groups!.find((x) => x.id === "g1")!.drawingIds).toEqual(["d1"]);
|
||||
expect(result.groups!.find((x) => x.id === "g2")!.drawingIds).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ungroup", () => {
|
||||
it("löst genau EIN Level auf; Untergruppen bleiben als eigene Gruppen bestehen", () => {
|
||||
const inner: Group = { id: "g1", wallIds: ["w1"], drawingIds: [], ceilingIds: [], roomIds: [], stairIds: [], columnIds: [], roofIds: [], openingIds: [], groupIds: [] };
|
||||
|
||||
@@ -227,6 +227,47 @@ export function applyGroupDimming(
|
||||
});
|
||||
}
|
||||
|
||||
/** IDs, die in `after` neu hinzugekommen sind (in `before` noch nicht vorhanden). */
|
||||
function newIds(before: { id: string }[], after: { id: string }[]): string[] {
|
||||
const beforeIds = new Set(before.map((x) => x.id));
|
||||
return after.filter((x) => !beforeIds.has(x.id)).map((x) => x.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeichnet man WÄHREND man eine Gruppe betreten hat (Isolation) ein neues
|
||||
* Element, wird es automatisch Mitglied dieser (innersten) Gruppe — man
|
||||
* zeichnet ja SICHTLICH „in" ihr (Nutzer-Wunsch, Vectorworks-Verhalten).
|
||||
* Vergleicht `before`/`after` je Elementart auf neu hinzugekommene IDs (Diff
|
||||
* über die commit-Mutation) und hängt sie an die aktive Gruppe an. `before`
|
||||
* === `after` (keine Änderung) oder `groupPath` leer → `after` unverändert.
|
||||
*/
|
||||
export function addNewElementsToActiveGroup(
|
||||
before: Project,
|
||||
after: Project,
|
||||
groupPath: string[],
|
||||
): Project {
|
||||
if (groupPath.length === 0 || before === after) return after;
|
||||
const activeId = groupPath[groupPath.length - 1];
|
||||
const g = (after.groups ?? []).find((x) => x.id === activeId);
|
||||
if (!g) return after;
|
||||
const added: GroupMembership = {
|
||||
wallIds: newIds(before.walls, after.walls),
|
||||
drawingIds: newIds(before.drawings2d, after.drawings2d),
|
||||
ceilingIds: newIds(before.ceilings ?? [], after.ceilings ?? []),
|
||||
roomIds: newIds(before.rooms ?? [], after.rooms ?? []),
|
||||
stairIds: newIds(before.stairs ?? [], after.stairs ?? []),
|
||||
columnIds: newIds(before.columns ?? [], after.columns ?? []),
|
||||
roofIds: newIds(before.roofs ?? [], after.roofs ?? []),
|
||||
openingIds: newIds(before.openings ?? [], after.openings ?? []),
|
||||
};
|
||||
if (KEYS.every((k) => added[k].length === 0)) return after;
|
||||
const merged: Group = { ...g, ...mergeMembership(g, added) };
|
||||
return {
|
||||
...after,
|
||||
groups: (after.groups ?? []).map((x) => (x.id === activeId ? merged : x)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt eine neue Gruppe aus einer Mengen-Auswahl (+ optional bereits
|
||||
* gewählte Gruppen, die dabei als Untergruppen verschachtelt werden — „zwei
|
||||
|
||||
@@ -502,6 +502,7 @@ export function useTransformShortcuts(
|
||||
) => void,
|
||||
selectedWallIds: string[],
|
||||
selectedDrawingId: string | null,
|
||||
selectedDrawingIds: string[],
|
||||
selectedExtrudedSolidId: string | null,
|
||||
selectedColumnId: string | null,
|
||||
engine: CommandEngine,
|
||||
@@ -543,6 +544,7 @@ export function useTransformShortcuts(
|
||||
const hasSel =
|
||||
selectedWallIds.length > 0 ||
|
||||
!!selectedDrawingId ||
|
||||
selectedDrawingIds.length > 0 ||
|
||||
!!selectedExtrudedSolidId ||
|
||||
!!selectedColumnId;
|
||||
if (!hasSel) return;
|
||||
@@ -567,6 +569,7 @@ export function useTransformShortcuts(
|
||||
activeTransform,
|
||||
selectedWallIds,
|
||||
selectedDrawingId,
|
||||
selectedDrawingIds,
|
||||
selectedExtrudedSolidId,
|
||||
selectedColumnId,
|
||||
engine,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* `commitTransform`/`transformPreview` mit MEHREREN gewählten 2D-Elementen
|
||||
* (`TransformSelection.drawingIds`) — Nutzer-Report: "wenn ich zwei Linien
|
||||
* anwähle, dass ich dann m usw nicht verwenden kann". Vorher trug
|
||||
* `TransformSelection` nur ein einzelnes `drawingId`, das bei einer
|
||||
* Mehrfachauswahl (2+ Elemente) `null` war → Bewegen/Kopieren/Spiegeln/
|
||||
* Drehen/Feld griffen gar nicht.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { commitTransform } from "./transform";
|
||||
import type { TransformSelection } from "./transform";
|
||||
import type { Drawing2D, Project } from "../model/types";
|
||||
|
||||
function project(drawings: Drawing2D[]): Project {
|
||||
return {
|
||||
id: "t",
|
||||
name: "T",
|
||||
lineStyles: [],
|
||||
hatches: [],
|
||||
components: [],
|
||||
wallTypes: [],
|
||||
drawingLevels: [],
|
||||
layers: [],
|
||||
walls: [],
|
||||
doors: [],
|
||||
drawings2d: drawings,
|
||||
};
|
||||
}
|
||||
|
||||
function line(id: string, x: number): Drawing2D {
|
||||
return {
|
||||
id,
|
||||
type: "drawing2d",
|
||||
levelId: "eg",
|
||||
categoryCode: "20",
|
||||
geom: { shape: "line", a: { x, y: 0 }, b: { x: x + 1, y: 0 } },
|
||||
};
|
||||
}
|
||||
|
||||
describe("commitTransform — Mehrfachauswahl (drawingIds)", () => {
|
||||
it("move: verschiebt ALLE gewählten 2D-Elemente, nicht nur eines", () => {
|
||||
const p = project([line("d1", 0), line("d2", 5), line("d3", 10)]);
|
||||
const sel: TransformSelection = { wallIds: [], drawingId: null, drawingIds: ["d1", "d2"] };
|
||||
const next = commitTransform(p, sel, "move", [{ x: 0, y: 0 }, { x: 2, y: 3 }], "move", 1);
|
||||
const d1 = next.drawings2d.find((d) => d.id === "d1")!.geom;
|
||||
const d2 = next.drawings2d.find((d) => d.id === "d2")!.geom;
|
||||
const d3 = next.drawings2d.find((d) => d.id === "d3")!.geom;
|
||||
if (d1.shape === "line") expect(d1.a).toEqual({ x: 2, y: 3 });
|
||||
if (d2.shape === "line") expect(d2.a).toEqual({ x: 7, y: 3 }); // 5+2
|
||||
if (d3.shape === "line") expect(d3.a).toEqual({ x: 10, y: 0 }); // nicht gewählt, unverändert
|
||||
});
|
||||
|
||||
it("copy: dupliziert ALLE gewählten 2D-Elemente (Original bleibt)", () => {
|
||||
const p = project([line("d1", 0), line("d2", 5)]);
|
||||
const sel: TransformSelection = { wallIds: [], drawingId: null, drawingIds: ["d1", "d2"] };
|
||||
const next = commitTransform(p, sel, "move", [{ x: 0, y: 0 }, { x: 1, y: 0 }], "copy", 1);
|
||||
expect(next.drawings2d.length).toBe(4); // 2 Original + 2 Kopien
|
||||
});
|
||||
|
||||
it("array: dupliziert ALLE gewählten 2D-Elemente N-mal", () => {
|
||||
const p = project([line("d1", 0), line("d2", 5)]);
|
||||
const sel: TransformSelection = { wallIds: [], drawingId: null, drawingIds: ["d1", "d2"] };
|
||||
const next = commitTransform(p, sel, "move", [{ x: 0, y: 0 }, { x: 1, y: 0 }], "array", 3);
|
||||
expect(next.drawings2d.length).toBe(8); // 2 Original + 2×3 Kopien
|
||||
});
|
||||
|
||||
it("weiterhin abwärtskompatibel: einzelnes `drawingId` (ohne drawingIds) funktioniert unverändert", () => {
|
||||
const p = project([line("d1", 0)]);
|
||||
const sel: TransformSelection = { wallIds: [], drawingId: "d1" };
|
||||
const next = commitTransform(p, sel, "move", [{ x: 0, y: 0 }, { x: 3, y: 0 }], "move", 1);
|
||||
const d1 = next.drawings2d[0].geom;
|
||||
if (d1.shape === "line") expect(d1.a).toEqual({ x: 3, y: 0 });
|
||||
});
|
||||
|
||||
it("drawingId + drawingIds zusammen werden dedupliziert (kein Doppel-Verschieben)", () => {
|
||||
const p = project([line("d1", 0), line("d2", 5)]);
|
||||
const sel: TransformSelection = { wallIds: [], drawingId: "d1", drawingIds: ["d1", "d2"] };
|
||||
const next = commitTransform(p, sel, "move", [{ x: 0, y: 0 }, { x: 1, y: 0 }], "copy", 1);
|
||||
expect(next.drawings2d.length).toBe(4); // je EINE Kopie pro Element, nicht zwei für d1
|
||||
});
|
||||
});
|
||||
+19
-6
@@ -30,13 +30,27 @@ export type CopyMode = "move" | "copy" | "array" | "distribute"; // U / I / O /
|
||||
/** Welche Auswahl transformiert wird. */
|
||||
export interface TransformSelection {
|
||||
wallIds: string[];
|
||||
/**
|
||||
* Einzeln gewähltes 2D-Element (Legacy-Feld, s. `CommandSelection.drawingId`).
|
||||
* `drawingIds` (Mehrfachauswahl) wird bevorzugt ausgewertet; beide zusammen
|
||||
* ergeben die MENGE der zu transformierenden 2D-Elemente (Union, dedupliziert).
|
||||
*/
|
||||
drawingId: string | null;
|
||||
/** ALLE gewählten 2D-Elemente (Mehrfachauswahl) — s. `CommandSelection.drawingIds`. */
|
||||
drawingIds?: string[];
|
||||
/** Gewählter extrudierter Körper (truck-Integration); nur move.ts setzt ihn. */
|
||||
extrudedSolidId?: string | null;
|
||||
/** Gewählte Stütze (Tragwerk); von move/copy/mirror gesetzt. */
|
||||
columnId?: string | null;
|
||||
}
|
||||
|
||||
/** Vereinigt `drawingId`/`drawingIds` zu einer eindeutigen ID-Menge (dedupliziert). */
|
||||
function drawingIdSet(sel: TransformSelection): Set<string> {
|
||||
const ids = new Set(sel.drawingIds ?? []);
|
||||
if (sel.drawingId) ids.add(sel.drawingId);
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** Wie viele Klickpunkte die Operation braucht (Drehen = 3, sonst 2). */
|
||||
export const requiredPoints = (op: TransformOp): number => (op === "rotate" ? 3 : 2);
|
||||
|
||||
@@ -116,10 +130,8 @@ function gatherItems(project: Project, sel: TransformSelection): AnyItem[] {
|
||||
const items: AnyItem[] = [];
|
||||
const ids = new Set(sel.wallIds);
|
||||
for (const w of project.walls) if (ids.has(w.id)) items.push({ kind: "wall", wall: w });
|
||||
if (sel.drawingId) {
|
||||
const d = project.drawings2d.find((x) => x.id === sel.drawingId);
|
||||
if (d) items.push({ kind: "drawing", drawing: d });
|
||||
}
|
||||
const drawIds = drawingIdSet(sel);
|
||||
for (const d of project.drawings2d) if (drawIds.has(d.id)) items.push({ kind: "drawing", drawing: d });
|
||||
if (sel.extrudedSolidId) {
|
||||
const s = (project.extrudedSolids ?? []).find((x) => x.id === sel.extrudedSolidId);
|
||||
if (s) items.push({ kind: "extrudedSolid", solid: s });
|
||||
@@ -292,9 +304,10 @@ export function commitTransform(
|
||||
if (movesOriginal(mode)) {
|
||||
const fn = transformFn(op, pts, 1);
|
||||
walls = walls.map((w) => (wallIds.has(w.id) ? { ...w, start: fn(w.start), end: fn(w.end) } : w));
|
||||
if (sel.drawingId) {
|
||||
const moveDrawIds = drawingIdSet(sel);
|
||||
if (moveDrawIds.size > 0) {
|
||||
drawings2d = drawings2d.map((d) =>
|
||||
d.id === sel.drawingId ? { ...d, geom: mvGeom(d.geom, fn) } : d,
|
||||
moveDrawIds.has(d.id) ? { ...d, geom: mvGeom(d.geom, fn) } : d,
|
||||
);
|
||||
}
|
||||
if (sel.extrudedSolidId) {
|
||||
|
||||
Reference in New Issue
Block a user