2D: Text/Textspalte auf Inline-Editing direkt auf der Zeichenfläche umgestellt
Nutzer-Report: Textfelder waren nicht mehrzeilig. Ursache: die Text-/ Textspalte-Werkzeuge holten ihre Eingabe über die Befehlszeile (CommandLine.tsx) — ein einzeiliges <input>, Enter committet sofort, kein Zeilenumbruch möglich. Das betraf auch "Textspalte", obwohl die explizit für mehrzeiligen Wortumbruch gedacht ist. Lösung (Nutzer-Vorgabe: kein Dialog, direktes Editieren auf der Fläche wie im Layout-System): text.ts/textbox.ts committen nach dem Platzieren (Anker bzw. Anker+Breite) sofort ein LEERES Text-Element und melden `focusDrawingId` — dafür CommandResult/EngineHost um einen Passthrough erweitert (engine.ts applyResult ruft host.focusDrawing(id)). App.tsx setzt darauf Auswahl + editTextId (dieselbe State-Variable wie beim bisherigen Doppelklick-Editieren). PlanView rendert bei gesetztem editTextId jetzt einen RichTextEditor-Overlay direkt über dem Element (position:fixed via vbToClient/toScreen, eigene Mini-Toolbar erscheint darüber) statt des bisherigen TextEditorDialog-Modals — Prinzip 1:1 vom Layout-Blatt-Inline- Editor (LayoutSheet.tsx) übernommen. Die neuen Props (editTextId/ onCommitTextEdit/onCloseTextEdit) laufen durch Content/LevelPlanView/ SectionPlanView (viewportContent.tsx) bis zu PlanView durch. Verhalten der beiden Werkzeuge (Nutzer-Vorgabe): "text" bleibt ohne Wortumbruch (kein `width`) — Enter erzeugt weitere Zeilen, aber keine automatische Breite. "textbox" setzt `width` und verhält sich damit wie ein InDesign-Textrahmen (echter Wortumbruch live beim Tippen, kein reines Render-Detail mehr). commitTextEdit (useMouseSelectionHandlers.ts) schreibt jetzt bei JEDEM Tastendruck (kein Guard mehr gegen leeren Text — nötig, damit Löschen bis auf null Zeichen sich sofort spiegelt); neues closeTextEdit räumt ein beim Schliessen noch leeres Element auf (verhindert Leichen bei abgebrochener Neuerstellung). 4 neue Tests (text/textbox: sofortiger Commit + focusDrawingId). tsc -b / vitest run (918/918) / npm run build grün. Rendering/Positionierung des Overlays selbst nicht automatisiert testbar — Nutzer prüft visuell in der Tauri-App.
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* `textCommand` — committet seit der Umstellung auf Inline-Editing (kein
|
||||
* Dialog/keine Befehlszeilen-Eingabe mehr, Nutzer-Wunsch) SOFORT ein leeres
|
||||
* Text-Element nach dem Ankerpunkt und meldet `focusDrawingId`, damit die UI
|
||||
* (PlanView) direkt den Inline-Rich-Text-Editor öffnet.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { textCommand } from "./text";
|
||||
import type { CommandContext, Project } from "../types";
|
||||
|
||||
function project(): Project {
|
||||
return {
|
||||
id: "t",
|
||||
name: "T",
|
||||
lineStyles: [],
|
||||
hatches: [],
|
||||
components: [],
|
||||
wallTypes: [],
|
||||
drawingLevels: [
|
||||
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 },
|
||||
],
|
||||
layers: [{ code: "20", name: "Zeichnung", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
|
||||
walls: [],
|
||||
doors: [],
|
||||
openings: [],
|
||||
ceilings: [],
|
||||
stairs: [],
|
||||
rooms: [],
|
||||
drawings2d: [],
|
||||
context: [],
|
||||
};
|
||||
}
|
||||
|
||||
function makeCtx(p: Project): CommandContext {
|
||||
return {
|
||||
project: p,
|
||||
level: p.drawingLevels[0],
|
||||
defaultCategoryCode: "20",
|
||||
activeLineStyleId: "solid",
|
||||
activeWallTypeId: "aw",
|
||||
lastPoint: null,
|
||||
selection: { wallIds: [], drawingId: null },
|
||||
};
|
||||
}
|
||||
|
||||
describe("textCommand", () => {
|
||||
it("committet sofort ein leeres Text-Element und meldet focusDrawingId", () => {
|
||||
const p = project();
|
||||
const ctx = makeCtx(p);
|
||||
const [, result] = textCommand.onInput(
|
||||
textCommand.init(),
|
||||
{ kind: "point", point: { x: 3, y: 4 } },
|
||||
ctx,
|
||||
);
|
||||
expect(result.done).toBe(true);
|
||||
expect(result.commit).toBeDefined();
|
||||
expect(result.focusDrawingId).toBeDefined();
|
||||
|
||||
const next = result.commit!(p);
|
||||
expect(next.drawings2d.length).toBe(1);
|
||||
const d = next.drawings2d[0];
|
||||
expect(d.id).toBe(result.focusDrawingId); // dieselbe ID wie im Commit erzeugt
|
||||
expect(d.geom.shape).toBe("text");
|
||||
if (d.geom.shape === "text") {
|
||||
expect(d.geom.text).toBe("");
|
||||
expect(d.geom.at).toEqual({ x: 3, y: 4 });
|
||||
expect(d.geom.width).toBeUndefined(); // Text-Werkzeug: KEIN Wortumbruch
|
||||
}
|
||||
});
|
||||
});
|
||||
+25
-35
@@ -1,21 +1,16 @@
|
||||
// Text — modellverankerter Einzeltext als 2D-Element. Schritte:
|
||||
// 1) „Ankerpunkt:" → Punkt (Position im Modell)
|
||||
// 2) „Text:" → getippte Zeile (das Label) → commit Drawing2D {shape:"text"}
|
||||
//
|
||||
// Der Text-Schritt nimmt Freitext an (accepts:["text"]) — die Engine reicht die
|
||||
// komplette getippte Zeile 1:1 durch (auch Zahlen/Kommas). Gerendert wird der
|
||||
// Text in generatePlan als `drawingText` (SVG), analog zum DXF-Import.
|
||||
// Committet SOFORT ein leeres Text-Element und meldet `focusDrawingId` —
|
||||
// die UI (PlanView) öffnet daraufhin direkt den Inline-Rich-Text-Editor AUF
|
||||
// der Zeichenfläche (kein Dialog, keine Befehlszeilen-Eingabe). Ohne
|
||||
// Spaltenbreite bricht der Text NICHT automatisch um: einzeilig, bis der
|
||||
// Nutzer selbst Enter drückt (siehe `textbox` fürs InDesign-artige
|
||||
// Wortumbruch-Textfeld). Bleibt der Editor leer, verwirft `commitTextEdit`
|
||||
// (App.tsx) das Element wieder.
|
||||
|
||||
import type { Drawing2D } from "../../model/types";
|
||||
import { uniqueId } from "../../tools/types";
|
||||
import type {
|
||||
Command,
|
||||
CommandContext,
|
||||
CommandResult,
|
||||
CommandState,
|
||||
Project,
|
||||
Vec2,
|
||||
} from "../types";
|
||||
import type { Command, CommandContext, CommandResult, CommandState, Project, Vec2 } from "../types";
|
||||
|
||||
/** Default-Schrifthöhe eines frei platzierten Texts in Modell-Metern. */
|
||||
const DEFAULT_TEXT_HEIGHT_M = 0.25;
|
||||
@@ -23,50 +18,45 @@ const DEFAULT_TEXT_HEIGHT_M = 0.25;
|
||||
interface TextIdle extends CommandState {
|
||||
phase: "point";
|
||||
}
|
||||
interface TextLabel extends CommandState {
|
||||
phase: "label";
|
||||
at: Vec2;
|
||||
}
|
||||
type TextState = TextIdle | TextLabel;
|
||||
type TextState = TextIdle;
|
||||
|
||||
function appendText(p: Project, at: Vec2, text: string, ctx: CommandContext): Project {
|
||||
const label = text.trim();
|
||||
if (label === "") return p;
|
||||
function appendEmptyText(p: Project, id: string, at: Vec2, ctx: CommandContext): Project {
|
||||
const d: Drawing2D = {
|
||||
id: uniqueId("dr2d"),
|
||||
id,
|
||||
type: "drawing2d",
|
||||
levelId: ctx.level.id,
|
||||
categoryCode: ctx.defaultCategoryCode,
|
||||
geom: { shape: "text", at, text: label, height: DEFAULT_TEXT_HEIGHT_M, angle: 0 },
|
||||
geom: { shape: "text", at, text: "", height: DEFAULT_TEXT_HEIGHT_M, angle: 0 },
|
||||
};
|
||||
return { ...p, drawings2d: [...p.drawings2d, d] };
|
||||
}
|
||||
|
||||
const idle = (): [CommandState, CommandResult] => [
|
||||
{ phase: "point", lastPoint: null },
|
||||
{ phase: "point", lastPoint: null } as TextIdle,
|
||||
{ draft: null, done: true },
|
||||
];
|
||||
|
||||
export const textCommand: Command = {
|
||||
name: "text",
|
||||
labelKey: "cmd.text.label",
|
||||
prompt: (s) => ((s as TextState).phase === "label" ? "cmd.text.enter" : "cmd.text.point"),
|
||||
accepts: (s) => ((s as TextState).phase === "label" ? ["text"] : ["point"]),
|
||||
prompt: () => "cmd.text.point",
|
||||
accepts: () => ["point"],
|
||||
options: () => [],
|
||||
init: (): TextIdle => ({ phase: "point", lastPoint: null }),
|
||||
|
||||
onInput: (state, input, ctx): [CommandState, CommandResult] => {
|
||||
const s = state as TextState;
|
||||
if (s.phase !== "label") {
|
||||
if (input.kind !== "point") return [s, { draft: null }];
|
||||
const ns: TextLabel = { phase: "label", at: input.point, lastPoint: input.point };
|
||||
return [ns, { draft: null }];
|
||||
}
|
||||
if (input.kind !== "text") return [s, { draft: null }];
|
||||
const at = s.at;
|
||||
if (input.kind !== "point") return [s, { draft: null }];
|
||||
const id = uniqueId("dr2d");
|
||||
const at = input.point;
|
||||
return [
|
||||
{ phase: "point", lastPoint: null },
|
||||
{ draft: null, done: true, commit: (p) => appendText(p, at, input.text, ctx) },
|
||||
{ phase: "point", lastPoint: null } as TextIdle,
|
||||
{
|
||||
draft: null,
|
||||
done: true,
|
||||
commit: (p) => appendEmptyText(p, id, at, ctx),
|
||||
focusDrawingId: id,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* `textboxCommand` — committet seit der Umstellung auf Inline-Editing (kein
|
||||
* Dialog/keine Befehlszeilen-Eingabe mehr, InDesign-artiges Textrahmen-
|
||||
* Verhalten, Nutzer-Wunsch) SOFORT ein leeres Text-Element MIT Spaltenbreite
|
||||
* nach Anker+Breite und meldet `focusDrawingId`.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { textboxCommand } from "./textbox";
|
||||
import type { CommandContext, Project } from "../types";
|
||||
|
||||
function project(): Project {
|
||||
return {
|
||||
id: "t",
|
||||
name: "T",
|
||||
lineStyles: [],
|
||||
hatches: [],
|
||||
components: [],
|
||||
wallTypes: [],
|
||||
drawingLevels: [
|
||||
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 },
|
||||
],
|
||||
layers: [{ code: "20", name: "Zeichnung", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
|
||||
walls: [],
|
||||
doors: [],
|
||||
openings: [],
|
||||
ceilings: [],
|
||||
stairs: [],
|
||||
rooms: [],
|
||||
drawings2d: [],
|
||||
context: [],
|
||||
};
|
||||
}
|
||||
|
||||
function makeCtx(p: Project): CommandContext {
|
||||
return {
|
||||
project: p,
|
||||
level: p.drawingLevels[0],
|
||||
defaultCategoryCode: "20",
|
||||
activeLineStyleId: "solid",
|
||||
activeWallTypeId: "aw",
|
||||
lastPoint: null,
|
||||
selection: { wallIds: [], drawingId: null },
|
||||
};
|
||||
}
|
||||
|
||||
describe("textboxCommand", () => {
|
||||
it("committet nach Anker+Breite sofort ein leeres Text-Element mit width und meldet focusDrawingId", () => {
|
||||
const p = project();
|
||||
const ctx = makeCtx(p);
|
||||
|
||||
const [afterPoint] = textboxCommand.onInput(
|
||||
textboxCommand.init(),
|
||||
{ kind: "point", point: { x: 0, y: 0 } },
|
||||
ctx,
|
||||
);
|
||||
const [, result] = textboxCommand.onInput(
|
||||
afterPoint,
|
||||
{ kind: "point", point: { x: 2, y: 0 } }, // Spaltenbreite = 2m
|
||||
ctx,
|
||||
);
|
||||
expect(result.done).toBe(true);
|
||||
expect(result.commit).toBeDefined();
|
||||
expect(result.focusDrawingId).toBeDefined();
|
||||
|
||||
const next = result.commit!(p);
|
||||
expect(next.drawings2d.length).toBe(1);
|
||||
const d = next.drawings2d[0];
|
||||
expect(d.id).toBe(result.focusDrawingId);
|
||||
expect(d.geom.shape).toBe("text");
|
||||
if (d.geom.shape === "text") {
|
||||
expect(d.geom.text).toBe("");
|
||||
expect(d.geom.width).toBeCloseTo(2, 9); // InDesign-artiger Textrahmen: Breite gesetzt
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,14 @@
|
||||
// Textspalte (Absatztext) — wie das Text-Werkzeug, aber mit einer Spaltenbreite:
|
||||
// der Text bricht beim Rendern wortweise auf diese Breite um. Schritte:
|
||||
// der Text bricht beim Rendern UND beim Editieren wortweise auf diese Breite
|
||||
// um (InDesign-artiges Textrahmen-Verhalten, Nutzer-Wunsch). Schritte:
|
||||
// 1) „Ankerpunkt:" → Punkt (oben-links der Spalte)
|
||||
// 2) „Spaltenbreite:" → Punkt (die horizontale Distanz zum Anker = Breite;
|
||||
// Live-Vorschau der Breitenlinie)
|
||||
// 3) „Text:" → getippte Zeile → commit Drawing2D {shape:"text", width}
|
||||
// Committet danach SOFORT ein leeres Text-Element (mit `width`) und meldet
|
||||
// `focusDrawingId` — die UI (PlanView) öffnet den Inline-Rich-Text-Editor
|
||||
// direkt auf der Zeichenfläche, mit der Spaltenbreite als CSS-Breite (echter
|
||||
// Wortumbruch beim Tippen, kein Dialog). Bleibt der Editor leer, verwirft
|
||||
// `commitTextEdit` (App.tsx) das Element wieder.
|
||||
//
|
||||
// Reuse des bestehenden {shape:"text"}-Elements (mit `width`) — so erben Selektion,
|
||||
// Verschieben und Griff automatisch vom Einzeltext; nur die Breite kommt hinzu.
|
||||
@@ -33,12 +38,7 @@ interface TbWidth extends CommandState {
|
||||
at: Vec2;
|
||||
cursor: Vec2 | null;
|
||||
}
|
||||
interface TbLabel extends CommandState {
|
||||
phase: "label";
|
||||
at: Vec2;
|
||||
width: number;
|
||||
}
|
||||
type TbState = TbPoint | TbWidth | TbLabel;
|
||||
type TbState = TbPoint | TbWidth;
|
||||
|
||||
/** Horizontale Spaltenbreite aus Anker + Cursor (Betrag der x-Differenz, geklemmt). */
|
||||
function widthOf(at: Vec2, cursor: Vec2): number {
|
||||
@@ -56,21 +56,13 @@ function widthDraft(at: Vec2, cursor: Vec2): ToolDraft {
|
||||
};
|
||||
}
|
||||
|
||||
function appendTextbox(
|
||||
p: Project,
|
||||
at: Vec2,
|
||||
width: number,
|
||||
text: string,
|
||||
ctx: CommandContext,
|
||||
): Project {
|
||||
const label = text.trim();
|
||||
if (label === "") return p;
|
||||
function appendEmptyTextbox(p: Project, id: string, at: Vec2, width: number, ctx: CommandContext): Project {
|
||||
const d: Drawing2D = {
|
||||
id: uniqueId("dr2d"),
|
||||
id,
|
||||
type: "drawing2d",
|
||||
levelId: ctx.level.id,
|
||||
categoryCode: ctx.defaultCategoryCode,
|
||||
geom: { shape: "text", at, text: label, height: DEFAULT_TEXT_HEIGHT_M, angle: 0, width },
|
||||
geom: { shape: "text", at, text: "", height: DEFAULT_TEXT_HEIGHT_M, angle: 0, width },
|
||||
};
|
||||
return { ...p, drawings2d: [...p.drawings2d, d] };
|
||||
}
|
||||
@@ -83,35 +75,30 @@ const idle = (): [CommandState, CommandResult] => [
|
||||
export const textboxCommand: Command = {
|
||||
name: "textbox",
|
||||
labelKey: "cmd.textbox.label",
|
||||
prompt: (s) => {
|
||||
const ts = s as TbState;
|
||||
if (ts.phase === "label") return "cmd.textbox.enter";
|
||||
if (ts.phase === "width") return "cmd.textbox.width";
|
||||
return "cmd.textbox.point";
|
||||
},
|
||||
accepts: (s) => ((s as TbState).phase === "label" ? ["text"] : ["point"]),
|
||||
prompt: (s) => ((s as TbState).phase === "width" ? "cmd.textbox.width" : "cmd.textbox.point"),
|
||||
accepts: () => ["point"],
|
||||
options: () => [],
|
||||
init: (): TbPoint => ({ phase: "point", lastPoint: null }),
|
||||
|
||||
onInput: (state, input, ctx): [CommandState, CommandResult] => {
|
||||
const s = state as TbState;
|
||||
if (input.kind !== "point") return [s, { draft: null }];
|
||||
if (s.phase === "point") {
|
||||
if (input.kind !== "point") return [s, { draft: null }];
|
||||
const ns: TbWidth = { phase: "width", at: input.point, cursor: input.point, lastPoint: input.point };
|
||||
return [ns, { draft: widthDraft(input.point, input.point) }];
|
||||
}
|
||||
if (s.phase === "width") {
|
||||
if (input.kind !== "point") return [s, { draft: s.cursor ? widthDraft(s.at, s.cursor) : null }];
|
||||
const width = widthOf(s.at, input.point);
|
||||
const ns: TbLabel = { phase: "label", at: s.at, width, lastPoint: input.point };
|
||||
return [ns, { draft: null }];
|
||||
}
|
||||
// label
|
||||
if (input.kind !== "text") return [s, { draft: null }];
|
||||
const { at, width } = s;
|
||||
// phase === "width"
|
||||
const width = widthOf(s.at, input.point);
|
||||
const id = uniqueId("dr2d");
|
||||
const at = s.at;
|
||||
return [
|
||||
{ phase: "point", lastPoint: null } as TbPoint,
|
||||
{ draft: null, done: true, commit: (p) => appendTextbox(p, at, width, input.text, ctx) },
|
||||
{
|
||||
draft: null,
|
||||
done: true,
|
||||
commit: (p) => appendEmptyTextbox(p, id, at, width, ctx),
|
||||
focusDrawingId: id,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
|
||||
@@ -52,6 +52,12 @@ export interface EngineHost {
|
||||
commit(mutate: (p: Project) => Project): void;
|
||||
/** Ist die aktive Ebene ein Geschoss? (für floorOnly-Befehle). */
|
||||
isFloor(): boolean;
|
||||
/**
|
||||
* Optional: ein frisch erzeugtes Drawing2D-Element fokussieren (Passthrough
|
||||
* von `CommandResult.focusDrawingId`, z. B. um sofort den Inline-Text-Editor
|
||||
* zu öffnen). Fehlt der Host-Handler, ist das ein No-op.
|
||||
*/
|
||||
focusDrawing?(drawingId: string): void;
|
||||
}
|
||||
|
||||
/** Öffentlicher Schnappschuss für die Command-Line-UI (rein lesend). */
|
||||
@@ -476,6 +482,7 @@ export class CommandEngine {
|
||||
|
||||
private applyResult(res: CommandResult): void {
|
||||
if (res.commit) this.host.commit(res.commit);
|
||||
if (res.focusDrawingId) this.host.focusDrawing?.(res.focusDrawingId);
|
||||
if (res.draft) this.host.setDraft(res.draft);
|
||||
else this.host.setDraft(null);
|
||||
if (res.done) {
|
||||
|
||||
@@ -109,6 +109,13 @@ export interface CommandResult {
|
||||
commit?: (p: Project) => Project;
|
||||
/** true → Befehl ist fertig und kehrt in den Ruhezustand zurück. */
|
||||
done?: boolean;
|
||||
/**
|
||||
* Bei Abschluss: die Engine soll dieses neu erzeugte Drawing2D-Element
|
||||
* sofort fokussieren (z. B. Inline-Text-Editor öffnen, wie `text`/`textbox`
|
||||
* nach dem Platzieren). Reiner Passthrough an `EngineHost.focusDrawing`;
|
||||
* die Engine selbst wertet die ID nicht aus.
|
||||
*/
|
||||
focusDrawingId?: string;
|
||||
}
|
||||
|
||||
// ── Befehls-Zustand ──────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user