2D: Inline-Text-Editor-Nachbesserung (unsichtbarer Anker) + Textspalte als echtes Rechteck

Nutzer-Report nach der Umstellung auf Inline-Editing: beides funktionierte
nicht — "Text" liess sich kein Ankerpunkt setzen, "Textspalte" sollte ein
Rechteck statt nur eine Breite sein.

Root Cause für "Text": `.planview-inline-text` bekam ohne Spaltenbreite
`width: undefined` — bei position:fixed ohne rechten Rand greift shrink-to-
fit-Sizing, das für ein LEERES contentEditable auf ~0px kollabiert. Der Klick
erzeugte tatsächlich ein Element + öffnete den Editor, beides war nur
unsichtbar (eine 0px-Box lässt sich nicht anklicken/fokussieren). Fix: neue
Klasse .planview-inline-text-free (kein wrapWidth) erzwingt `width:
max-content` + `white-space: pre` auf Editor/Surface (Box wächst mit dem
Inhalt statt umzubrechen — "Text" bleibt einzeilig bis Enter, wie gewünscht)
plus eine CSS-Mindestbreite (160px) für den leeren Startzustand.

"Textspalte" (textbox.ts) zeichnet jetzt ein RECHTECK (zwei diagonale Ecken,
beliebige Zugrichtung) statt nur eine horizontale Breitenlinie — die
aufgezogene Höhe wird nicht im Modell gespeichert (das Textformat kennt nur
`width` für den Wortumbruch, keine feste Rahmenhöhe), sondern als
Editor-MINDESThöhe durchgereicht: CommandResult/EngineHost.focusDrawing um
`focusMinHeightM` erweitert (Passthrough wie focusDrawingId), App.tsx hält
sie in einem neuen editTextMinHeightM-State (useContextMenuState.ts),
PlanView nutzt sie als CSS-min-height des Editors — der Rahmen wächst bei
mehr Text darüber hinaus, schrumpft aber nicht darunter (InDesign-Verhalten).
Beim Doppelklick-Editieren bestehender Elemente wird die Mindesthöhe
zurückgesetzt (kein Nachwirken einer vorigen Textspalten-Erstellung).

textbox.test.ts an die neue Rechteck-Geste angepasst (+ Test für beliebige
Zugrichtung). tsc -b / vitest run (919/919) / npm run build grün. Die
eigentliche Sichtbarkeits-Vermutung (CSS-Kollaps) konnte ich nicht
interaktiv im Browser verifizieren (kein Browser-Tool verfügbar) — Nutzer
prüft erneut in der laufenden App.
This commit is contained in:
2026-08-20 21:44:37 +02:00
parent f2d169cc67
commit 73d147b37d
12 changed files with 185 additions and 70 deletions
+34 -7
View File
@@ -2,7 +2,9 @@
* `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`.
* nach einem aufgezogenen RECHTECK (zwei diagonale Ecken, nicht nur eine
* Breitenlinie) und meldet `focusDrawingId` + `focusMinHeightM` (die
* aufgezogene Rahmenhöhe als Mindesthöhe des Inline-Editors).
*/
import { describe, it, expect } from "vitest";
@@ -45,23 +47,25 @@ function makeCtx(p: Project): CommandContext {
}
describe("textboxCommand", () => {
it("committet nach Anker+Breite sofort ein leeres Text-Element mit width und meldet focusDrawingId", () => {
it("committet nach einem Rechteck (2 Ecken) ein leeres Text-Element mit width + meldet focusDrawingId/focusMinHeightM", () => {
const p = project();
const ctx = makeCtx(p);
const [afterPoint] = textboxCommand.onInput(
const [afterFirst] = textboxCommand.onInput(
textboxCommand.init(),
{ kind: "point", point: { x: 0, y: 0 } },
{ kind: "point", point: { x: 0, y: 5 } },
ctx,
);
// Gegenecke: 3m breit, 1.5m hoch (Rahmen, keine reine Breitenlinie mehr).
const [, result] = textboxCommand.onInput(
afterPoint,
{ kind: "point", point: { x: 2, y: 0 } }, // Spaltenbreite = 2m
afterFirst,
{ kind: "point", point: { x: 3, y: 3.5 } },
ctx,
);
expect(result.done).toBe(true);
expect(result.commit).toBeDefined();
expect(result.focusDrawingId).toBeDefined();
expect(result.focusMinHeightM).toBeCloseTo(1.5, 9);
const next = result.commit!(p);
expect(next.drawings2d.length).toBe(1);
@@ -70,7 +74,30 @@ describe("textboxCommand", () => {
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
expect(d.geom.width).toBeCloseTo(3, 9); // InDesign-artiger Textrahmen: Breite gesetzt
expect(d.geom.at.x).toBeCloseTo(0, 9); // oben-links des Rahmens (unabhängig von der Zugrichtung)
}
});
it("beliebige Zugrichtung (Gegenecke oben-links vom Start): Rahmen wird trotzdem korrekt normalisiert", () => {
const p = project();
const ctx = makeCtx(p);
const [afterFirst] = textboxCommand.onInput(
textboxCommand.init(),
{ kind: "point", point: { x: 5, y: 2 } },
ctx,
);
const [, result] = textboxCommand.onInput(
afterFirst,
{ kind: "point", point: { x: 2, y: 4 } }, // Gegenecke LINKS und OBEN vom Start
ctx,
);
const next = result.commit!(p);
const d = next.drawings2d[0];
if (d.geom.shape === "text") {
expect(d.geom.width).toBeCloseTo(3, 9); // |5-2|
expect(d.geom.at.x).toBeCloseTo(2, 9); // min(x)
}
expect(result.focusMinHeightM).toBeCloseTo(2, 9); // |4-2|
});
});
+73 -45
View File
@@ -1,17 +1,23 @@
// Textspalte (Absatztext) — wie das Text-Werkzeug, aber mit einer Spaltenbreite:
// 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)
// Textspalte (Absatztext) — wie das Text-Werkzeug, aber als aufgezogener
// RAHMEN: Breite UND Höhe kommen aus einem Rechteck (zwei diagonale Punkte),
// nicht nur einer Breitenlinie (InDesign-artiges Textrahmen-Verhalten,
// Nutzer-Wunsch). Schritte:
// 1) „Erste Ecke:" → Punkt
// 2) „Gegenecke:" → Punkt (Live-Vorschau des Rahmens, beliebige Richtung)
// 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.
// `focusDrawingId` + `focusMinHeightM` (die aufgezogene Rahmenhöhe als
// MINDESThöhe, s. types.ts) — 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) und dem Rahmen als
// Mindesthöhe (wächst bei mehr Text darüber hinaus). Bleibt der Editor
// leer, verwirft `commitTextEdit`/`closeTextEdit` (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.
// Reuse des bestehenden {shape:"text"}-Elements (mit `width`) — so erben
// Selektion, Verschieben und Griff automatisch vom Einzeltext; die Modell-
// Geometrie selbst kennt aber keine feste Rahmenhöhe (nur `width` für den
// Wortumbruch) — die Höhe bleibt reine Editor-Startgrösse (PlanView-lokal,
// s. focusMinHeightM), die Textmenge bestimmt die tatsächliche Höhe.
import type { Drawing2D } from "../../model/types";
import { uniqueId } from "../../tools/types";
@@ -20,39 +26,61 @@ import type {
CommandContext,
CommandResult,
CommandState,
DraftShape,
Project,
ToolDraft,
Vec2,
} from "../types";
/** Default-Schrifthöhe einer Textspalte in Modell-Metern. */
/** Default-Schrifthöhe einer Textspalte in Modell-Metern (= Baseline-Versatz vom Rahmen-Top). */
const DEFAULT_TEXT_HEIGHT_M = 0.25;
/** Kleinste sinnvolle Spaltenbreite (Meter). */
const MIN_WIDTH_M = 0.2;
/** Kleinste sinnvolle Rahmen-Breite/-Höhe (Meter). */
const MIN_SIZE_M = 0.2;
interface TbPoint extends CommandState {
phase: "point";
interface TbFirst extends CommandState {
phase: "first";
}
interface TbWidth extends CommandState {
phase: "width";
at: Vec2;
interface TbSecond extends CommandState {
phase: "second";
a: Vec2;
cursor: Vec2 | null;
}
type TbState = TbPoint | TbWidth;
type TbState = TbFirst | TbSecond;
/** Horizontale Spaltenbreite aus Anker + Cursor (Betrag der x-Differenz, geklemmt). */
function widthOf(at: Vec2, cursor: Vec2): number {
return Math.max(MIN_WIDTH_M, Math.abs(cursor.x - at.x));
/** Normalisiertes Rechteck aus zwei diagonalen Punkten (beliebige Zugrichtung). */
function rectOf(a: Vec2, b: Vec2): { minX: number; maxX: number; minY: number; maxY: number } {
return {
minX: Math.min(a.x, b.x),
maxX: Math.max(a.x, b.x),
minY: Math.min(a.y, b.y),
maxY: Math.max(a.y, b.y),
};
}
/** Vorschau: waagrechte Breitenlinie am Anker (zeigt die Spaltenbreite). */
function widthDraft(at: Vec2, cursor: Vec2): ToolDraft {
const w = widthOf(at, cursor);
const b: Vec2 = { x: at.x + w, y: at.y };
/** Breite/Höhe (geklemmt auf MIN_SIZE_M) + die Textanker-Position (oben-links, als Baseline). */
function frameOf(a: Vec2, b: Vec2): { at: Vec2; width: number; heightM: number } {
const r = rectOf(a, b);
const width = Math.max(MIN_SIZE_M, r.maxX - r.minX);
const heightM = Math.max(MIN_SIZE_M, r.maxY - r.minY);
// `at` ist die Baseline-links der ERSTEN Zeile (s. drawing2d.ts/primitives.tsx);
// die Rahmen-Oberkante liegt eine Zeilenhöhe darüber → at.y = Rahmen-Top − Zeilenhöhe.
return { at: { x: r.minX, y: r.maxY - DEFAULT_TEXT_HEIGHT_M }, width, heightM };
}
/** Vorschau: Rahmen-Rechteck zwischen den beiden Ecken. */
function frameDraft(a: Vec2, cursor: Vec2): { preview: DraftShape[]; vertices: Vec2[]; hud: { at: Vec2; text: string } } {
const r = rectOf(a, cursor);
const pts: Vec2[] = [
{ x: r.minX, y: r.minY },
{ x: r.maxX, y: r.minY },
{ x: r.maxX, y: r.maxY },
{ x: r.minX, y: r.maxY },
];
const w = Math.max(MIN_SIZE_M, r.maxX - r.minX);
const h = Math.max(MIN_SIZE_M, r.maxY - r.minY);
return {
preview: [{ kind: "line", a: at, b }],
vertices: [at, b],
hud: { at: b, text: `${w.toFixed(2)} m` },
preview: [{ kind: "poly", pts, closed: true }],
vertices: [a, cursor],
hud: { at: cursor, text: `${w.toFixed(2)} × ${h.toFixed(2)} m` },
};
}
@@ -68,45 +96,45 @@ function appendEmptyTextbox(p: Project, id: string, at: Vec2, width: number, ctx
}
const idle = (): [CommandState, CommandResult] => [
{ phase: "point", lastPoint: null } as TbPoint,
{ phase: "first", lastPoint: null } as TbFirst,
{ draft: null, done: true },
];
export const textboxCommand: Command = {
name: "textbox",
labelKey: "cmd.textbox.label",
prompt: (s) => ((s as TbState).phase === "width" ? "cmd.textbox.width" : "cmd.textbox.point"),
prompt: (s) => ((s as TbState).phase === "second" ? "cmd.textbox.second" : "cmd.textbox.first"),
accepts: () => ["point"],
options: () => [],
init: (): TbPoint => ({ phase: "point", lastPoint: null }),
init: (): TbFirst => ({ phase: "first", 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") {
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 === "first") {
const ns: TbSecond = { phase: "second", a: input.point, cursor: input.point, lastPoint: input.point };
return [ns, { draft: { preview: [], vertices: [input.point] } }];
}
// phase === "width"
const width = widthOf(s.at, input.point);
// phase === "second"
const { at, width, heightM } = frameOf(s.a, input.point);
const id = uniqueId("dr2d");
const at = s.at;
return [
{ phase: "point", lastPoint: null } as TbPoint,
{ phase: "first", lastPoint: null } as TbFirst,
{
draft: null,
done: true,
commit: (p) => appendEmptyTextbox(p, id, at, width, ctx),
focusDrawingId: id,
focusMinHeightM: heightM,
},
];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as TbState;
if (s.phase !== "width") return [s, { draft: null }];
const ns: TbWidth = { ...s, cursor: point };
return [ns, { draft: widthDraft(s.at, point) }];
if (s.phase !== "second") return [s, { draft: null }];
const ns: TbSecond = { ...s, cursor: point };
return [ns, { draft: frameDraft(s.a, point) }];
},
onConfirm: (): [CommandState, CommandResult] => idle(),
+4 -4
View File
@@ -54,10 +54,10 @@ export interface EngineHost {
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.
* von `CommandResult.focusDrawingId`/`focusMinHeightM`, z. B. um sofort den
* Inline-Text-Editor zu öffnen). Fehlt der Host-Handler, ist das ein No-op.
*/
focusDrawing?(drawingId: string): void;
focusDrawing?(drawingId: string, minHeightM?: number): void;
}
/** Öffentlicher Schnappschuss für die Command-Line-UI (rein lesend). */
@@ -482,7 +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.focusDrawingId) this.host.focusDrawing?.(res.focusDrawingId, res.focusMinHeightM);
if (res.draft) this.host.setDraft(res.draft);
else this.host.setDraft(null);
if (res.done) {
+7
View File
@@ -116,6 +116,13 @@ export interface CommandResult {
* die Engine selbst wertet die ID nicht aus.
*/
focusDrawingId?: string;
/**
* Nur zusammen mit `focusDrawingId`: die vom Nutzer aufgezogene Rahmenhöhe
* (Meter) als MINDESTHöhe des Inline-Editors (InDesign-Textrahmen — wächst
* bei Bedarf über diese Höhe hinaus, schrumpft aber nicht darunter). Fehlt
* sie, bestimmt allein der Inhalt die Höhe (Text-Werkzeug ohne Rahmen).
*/
focusMinHeightM?: number;
}
// ── Befehls-Zustand ──────────────────────────────────────────────────────────