diff --git a/src/App.tsx b/src/App.tsx index f9b6f0c..205f802 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -796,6 +796,25 @@ export default function App() { setDraft: (d) => cmdLatest.current.setDraft(d), commit: (mutate) => cmdLatest.current.setProject(mutate), 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 + // applyResult-Reihenfolge commit→focusDrawing). Auswahl-Löschen wie + // onEditDrawingText (useMouseSelectionHandlers.ts), damit kein anderes + // Element gleichzeitig markiert bleibt. + focusDrawing: (id) => { + setSelectedWallIds([]); + setSelectedCeilingIds([]); + setSelectedOpeningIds([]); + setSelectedStairIds([]); + setSelectedRoomIds([]); + setSelectedExtrudedSolidIds([]); + setSelectedColumnIds([]); + setSelectedRoofIds([]); + setSelectedContextObjectIds([]); + setSelectedSectionLineId(null); + setSelectedDrawingId(id); + setEditTextId(id); + }, }; engineRef.current = new CommandEngine(host); } @@ -2226,6 +2245,7 @@ export default function App() { onOpenStampEditor, onEditDrawingText, commitTextEdit, + closeTextEdit, onPlanSelect, onPlanMarquee, onPlanContextMenu, @@ -2715,6 +2735,9 @@ export default function App() { onStampMove={moveRoomStamp} onStampEdit={onOpenStampEditor} onEditDrawingText={onEditDrawingText} + editTextId={editTextId} + onCommitTextEdit={commitTextEdit} + onCloseTextEdit={closeTextEdit} onPlanSelect={onPlanSelect} onPlanMarquee={onPlanMarquee} onSegmentCut={onSegmentCut} @@ -3022,24 +3045,11 @@ export default function App() { /> )} - {/* Rich-Text-Editor: per Doppelklick auf ein Text-2D-Element geöffnet (wie - beim Raum-Stempel) — mehrere Absätze, gemischte Formatierung pro - Zeichen (InDesign-/Affinity-artig). Alt-Elemente ohne `doc` starten - aus ihrem bisherigen String + einheitlichen `marks`. */} - {editTextId && - (() => { - const d = project.drawings2d.find((x) => x.id === editTextId); - if (!d || d.geom.shape !== "text") return null; - const g = d.geom; - const doc = g.doc ?? docFromText(g.text, g.marks ?? {}); - return ( - setEditTextId(null)} - /> - ); - })()} + {/* Inline-Text-Editor für Drawing2D-Text (`editTextId`) rendert PlanView + jetzt direkt auf der Zeichenfläche (kein Dialog mehr, Nutzer-Wunsch — + gleiches Prinzip wie der Layout-Blatt-Inline-Editor). Ausgelöst über + `text`/`textbox` (sofort nach Platzieren, s. host.focusDrawing) oder + Doppelklick auf ein bestehendes Element (onEditDrawingText). */} {/* Text-Editor (schwebendes Fenster): Doppelklick auf einen Raumstempel bzw. Button in der Objekt-Info öffnet ihn. „Übernehmen" schreibt das diff --git a/src/commands/cmds/text.test.ts b/src/commands/cmds/text.test.ts new file mode 100644 index 0000000..4a20799 --- /dev/null +++ b/src/commands/cmds/text.test.ts @@ -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 + } + }); +}); diff --git a/src/commands/cmds/text.ts b/src/commands/cmds/text.ts index 69c399b..65027af 100644 --- a/src/commands/cmds/text.ts +++ b/src/commands/cmds/text.ts @@ -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, + }, ]; }, diff --git a/src/commands/cmds/textbox.test.ts b/src/commands/cmds/textbox.test.ts new file mode 100644 index 0000000..97ed91a --- /dev/null +++ b/src/commands/cmds/textbox.test.ts @@ -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 + } + }); +}); diff --git a/src/commands/cmds/textbox.ts b/src/commands/cmds/textbox.ts index 934a68c..a8e7336 100644 --- a/src/commands/cmds/textbox.ts +++ b/src/commands/cmds/textbox.ts @@ -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, + }, ]; }, diff --git a/src/commands/engine.ts b/src/commands/engine.ts index 8a00bfb..0c54491 100644 --- a/src/commands/engine.ts +++ b/src/commands/engine.ts @@ -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) { diff --git a/src/commands/types.ts b/src/commands/types.ts index 3c2ad51..77ca485 100644 --- a/src/commands/types.ts +++ b/src/commands/types.ts @@ -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 ────────────────────────────────────────────────────────── diff --git a/src/plan/PlanView.tsx b/src/plan/PlanView.tsx index b058b57..fbfcb47 100644 --- a/src/plan/PlanView.tsx +++ b/src/plan/PlanView.tsx @@ -16,6 +16,9 @@ import { import type { HatchRender, Plan, Primitive } from "./generatePlan"; import type { EdgeGrip, Vec2 } from "../model/types"; import type { ToolHandlers, ToolId, ToolMods } from "../tools/types"; +import type { RichTextDoc } from "../text/richText"; +import { docFromText } from "../text/richText"; +import { RichTextEditor } from "../text/RichTextEditor"; import { useGlPlanRenderer } from "./useGlPlanRenderer"; import { useWasmPlanRenderer } from "./useWasmPlanRenderer"; import { DEFAULT_MARQUEE_COLOR, DEFAULT_SNAP_COLOR } from "../theme/accents"; @@ -301,6 +304,19 @@ export interface PlanViewProps { onStampEdit?: (roomId: string) => void; /** Doppelklick auf ein Text-2D-Element: Inhalt bearbeiten (Editor öffnen). */ onEditDrawingText?: (drawingId: string) => void; + /** + * Drawing2D-ID, deren Inhalt gerade inline auf der Zeichenfläche editiert + * wird (frisch über `text`/`textbox` erzeugt ODER per Doppelklick auf ein + * bestehendes Element geöffnet) — `null`/undefined = kein Inline-Editor + * offen. PlanView rendert dafür einen `RichTextEditor`-Overlay direkt über + * dem Element (kein Dialog), positioniert/skaliert über den aktuellen + * Ausschnitt. + */ + editTextId?: string | null; + /** Jede Änderung im Inline-Text-Editor (live, pro Tastendruck). */ + onCommitTextEdit?: (doc: RichTextDoc) => void; + /** Inline-Text-Editor schliessen (Backdrop-Klick/Escape). */ + onCloseTextEdit?: () => void; /** Doppelklick auf eine Schnittlinie: in deren 2D-Schnittansicht springen * (Zeichnungsebene aktivieren). */ onOpenSectionLevel?: (levelId: string) => void; @@ -428,6 +444,9 @@ export const PlanView = forwardRef( onStampMove, onStampEdit, onEditDrawingText, + editTextId, + onCommitTextEdit, + onCloseTextEdit, onOpenSectionLevel, activeTool, toolInputActive, @@ -2450,6 +2469,60 @@ export const PlanView = forwardRef( /> )} + {/* Inline-Text-Editor DIREKT auf der Zeichenfläche (kein Dialog, Nutzer- + Feedback) — für ein frisch über `text`/`textbox` erzeugtes Element + (sofort nach dem Platzieren, s. App.tsx host.focusDrawing) ODER ein + per Doppelklick geöffnetes bestehendes. `RichTextEditor` bringt seine + eigene Mini-Toolbar mit (erscheint darüber, wie im Layout-Editor). + `at` ist die Baseline-links der ersten Zeile (s. primitives.tsx + "drawingText") — der Editor-Rahmen sitzt daher ~eine Zeilenhöhe + darüber. OHNE `wrapWidth` (Text-Werkzeug) bleibt die Breite frei + (kein Wortumbruch, nur Enter erzeugt neue Zeilen); MIT `wrapWidth` + (Textspalte) bricht der Editor live um wie ein InDesign-Textrahmen — + `position:"fixed"` mit echten Client-Pixeln (vbToClient), damit die + Positionierung unabhängig vom umgebenden Layout stimmt. */} + {editTextId && + (() => { + const prim = plan.primitives.find( + (p): p is Extract => + p.kind === "drawingText" && p.drawingId === editTextId, + ); + if (!prim) return null; + const doc = prim.doc ?? docFromText(prim.text, prim.marks ?? {}); + const pxPerM = currentPxPerMeter(); + const topAnchor = toScreen({ x: prim.at.x, y: prim.at.y + prim.heightM }); + const screen = vbToClient(topAnchor.x, topAnchor.y); + const FONT_PX_AT_12PT = 16; // 12pt @ 96dpi (RichTextEditor-Default, s. LayoutSheet.tsx). + const scale = (prim.heightM * pxPerM) / FONT_PX_AT_12PT; + const widthPx = prim.wrapWidth ? (prim.wrapWidth * pxPerM) / scale : undefined; + return ( + <> +
onCloseTextEdit?.()} + /> +
e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === "Escape") { + e.stopPropagation(); + onCloseTextEdit?.(); + } + }} + > + onCommitTextEdit?.(d)} autoFocus /> +
+ + ); + })()} {/* Lineale (oben + links, VW-Stil): Modell-Meter mit Ticks, Beschriftung und Cursor-Marker — screen-fix über den sichtbaren Ausschnitt gelegt. BEWUSST ein eigenes Sibling-SVG (nicht mehr Kind von `svgRef`): beim diff --git a/src/state/useMouseSelectionHandlers.ts b/src/state/useMouseSelectionHandlers.ts index 4d7838d..8cfcc1c 100644 --- a/src/state/useMouseSelectionHandlers.ts +++ b/src/state/useMouseSelectionHandlers.ts @@ -121,19 +121,38 @@ export function useMouseSelectionHandlers(opts: { // Bearbeitetes Rich-Text-Dokument übernehmen (immutabel; nur bei Text-Geometrie). // `text` bleibt als Reintext-Spiegel bestehen (DXF-Export, Suche, Alt-Konsumenten). + // LIVE bei jedem Tastendruck (Inline-Editor auf der Zeichenfläche, kein Dialog + // mehr) — schreibt AUCH einen leeren Zustand zurück (kein Guard mehr), damit + // Löschen bis auf null Zeichen sich sofort im Modell spiegelt; das eigentliche + // Aufräumen eines leer verlassenen NEUEN Elements übernimmt `closeTextEdit`. const commitTextEdit = (doc: RichTextDoc) => { const id = editTextId; if (!id) return; - const text = plainText(doc).trim(); - if (text !== "") { - setProject((p) => ({ - ...p, - drawings2d: p.drawings2d.map((d) => - d.id === id && d.geom.shape === "text" - ? { ...d, geom: { ...d.geom, doc, text: plainText(doc) } } - : d, - ), - })); + setProject((p) => ({ + ...p, + drawings2d: p.drawings2d.map((d) => + d.id === id && d.geom.shape === "text" + ? { ...d, geom: { ...d.geom, doc, text: plainText(doc) } } + : d, + ), + })); + }; + + // Inline-Editor schliessen (Backdrop-Klick/Escape): bleibt das Element leer + // (frisch über `text`/`textbox` erzeugt, nie befüllt, oder komplett wieder + // geleert), wird es entfernt statt als leere Leiche im Modell zu bleiben. + const closeTextEdit = () => { + const id = editTextId; + if (id) { + setProject((p) => { + const d = p.drawings2d.find((x) => x.id === id); + // `text` ist immer der Reintext-Spiegel von `doc` (s. commitTextEdit) — + // reicht daher allein als Leer-Test, egal ob `doc` je gesetzt wurde. + if (d && d.geom.shape === "text" && d.geom.text.trim() === "") { + return { ...p, drawings2d: p.drawings2d.filter((x) => x.id !== id) }; + } + return p; + }); } setEditTextId(null); }; @@ -509,6 +528,7 @@ export function useMouseSelectionHandlers(opts: { onOpenStampEditor, onEditDrawingText, commitTextEdit, + closeTextEdit, onPlanSelect, onPlanMarquee, onPlanContextMenu, diff --git a/src/styles.css b/src/styles.css index f1bc1d8..d2b8ae9 100644 --- a/src/styles.css +++ b/src/styles.css @@ -6770,6 +6770,30 @@ body { padding: 2px 4px; } +/* Inline-Text-Editor auf der 2D-Zeichenfläche (PlanView) — dasselbe Prinzip + wie .layout-sheet-inline-text/-backdrop, nur `position:fixed` mit echten + Client-Pixeln (vbToClient), da PlanView kein eigenes mm-Koordinatensystem + hat wie das Layout-Blatt. */ +.planview-inline-backdrop { + position: fixed; + inset: 0; + z-index: 6; +} +.planview-inline-text { + position: fixed; + z-index: 7; + background: #ffffff; + outline: 1.5px solid #3b82f6; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35); +} +.planview-inline-text .rt-editor { + min-height: inherit; +} +.planview-inline-text .rt-surface { + min-height: 1.4em; + padding: 2px 4px; +} + /* HUD (Eigenschaften des gewählten VP) unten links. */ .layout-sheet-hud { position: absolute; diff --git a/src/views/viewportContent.tsx b/src/views/viewportContent.tsx index c2168da..8b54347 100644 --- a/src/views/viewportContent.tsx +++ b/src/views/viewportContent.tsx @@ -22,6 +22,7 @@ import type { } from "../model/types"; import { generatePlan, generateSectionPlan } from "../plan/generatePlan"; import type { CategoryDisplayResolver } from "../plan/generatePlan"; +import type { RichTextDoc } from "../text/richText"; import { computeSection } from "../plan/toSection"; import type { SectionOutput } from "../plan/toSection"; import { generateElevationPlan } from "../plan/toElevation"; @@ -91,6 +92,9 @@ export function Content({ onStampMove, onStampEdit, onEditDrawingText, + editTextId, + onCommitTextEdit, + onCloseTextEdit, onPlanSelect, onPlanMarquee, onSegmentCut, @@ -177,6 +181,9 @@ export function Content({ onStampMove: (roomId: string, delta: Vec2) => void; onStampEdit: (roomId: string) => void; onEditDrawingText?: (drawingId: string) => void; + editTextId?: string | null; + onCommitTextEdit?: (doc: RichTextDoc) => void; + onCloseTextEdit?: () => void; onPlanSelect: (sel: PlanSelection) => void; onPlanMarquee: (sel: MarqueeSelection) => void; onSegmentCut: (drawingId: string, model: Vec2) => void; @@ -299,6 +306,9 @@ export function Content({ onStampMove={onStampMove} onStampEdit={onStampEdit} onEditDrawingText={onEditDrawingText} + editTextId={editTextId} + onCommitTextEdit={onCommitTextEdit} + onCloseTextEdit={onCloseTextEdit} onOpenSectionLevel={onSelectLevel} onPlanSelect={onPlanSelect} onPlanMarquee={onPlanMarquee} @@ -358,6 +368,9 @@ export function Content({ onStampMove={onStampMove} onStampEdit={onStampEdit} onEditDrawingText={onEditDrawingText} + editTextId={editTextId} + onCommitTextEdit={onCommitTextEdit} + onCloseTextEdit={onCloseTextEdit} onOpenSectionLevel={onSelectLevel} onPlanSelect={onPlanSelect} onPlanMarquee={onPlanMarquee} @@ -459,6 +472,9 @@ export function Content({ onStampMove={onStampMove} onStampEdit={onStampEdit} onEditDrawingText={onEditDrawingText} + editTextId={editTextId} + onCommitTextEdit={onCommitTextEdit} + onCloseTextEdit={onCloseTextEdit} onOpenSectionLevel={onSelectLevel} onPlanSelect={onPlanSelect} onPlanMarquee={onPlanMarquee} @@ -508,6 +524,9 @@ function LevelPlanView({ onStampMove, onStampEdit, onEditDrawingText, + editTextId, + onCommitTextEdit, + onCloseTextEdit, onOpenSectionLevel, onPlanSelect, onPlanMarquee, @@ -550,6 +569,9 @@ function LevelPlanView({ onStampMove: (roomId: string, delta: Vec2) => void; onStampEdit: (roomId: string) => void; onEditDrawingText?: (drawingId: string) => void; + editTextId?: string | null; + onCommitTextEdit?: (doc: RichTextDoc) => void; + onCloseTextEdit?: () => void; onOpenSectionLevel?: (id: string) => void; onPlanSelect: (sel: PlanSelection) => void; onPlanMarquee: (sel: MarqueeSelection) => void; @@ -631,6 +653,9 @@ function LevelPlanView({ onStampMove={onStampMove} onStampEdit={onStampEdit} onEditDrawingText={onEditDrawingText} + editTextId={editTextId} + onCommitTextEdit={onCommitTextEdit} + onCloseTextEdit={onCloseTextEdit} onOpenSectionLevel={onOpenSectionLevel} onSelect={onPlanSelect} onMarquee={onPlanMarquee} @@ -687,6 +712,9 @@ function SectionPlanView({ onStampMove, onStampEdit, onEditDrawingText, + editTextId, + onCommitTextEdit, + onCloseTextEdit, onOpenSectionLevel, onPlanSelect, onPlanMarquee, @@ -724,6 +752,9 @@ function SectionPlanView({ onStampMove: (roomId: string, delta: Vec2) => void; onStampEdit: (roomId: string) => void; onEditDrawingText?: (drawingId: string) => void; + editTextId?: string | null; + onCommitTextEdit?: (doc: RichTextDoc) => void; + onCloseTextEdit?: () => void; onOpenSectionLevel?: (id: string) => void; onPlanSelect: (sel: PlanSelection) => void; onPlanMarquee: (sel: MarqueeSelection) => void; @@ -831,6 +862,9 @@ function SectionPlanView({ onStampMove={onStampMove} onStampEdit={onStampEdit} onEditDrawingText={onEditDrawingText} + editTextId={editTextId} + onCommitTextEdit={onCommitTextEdit} + onCloseTextEdit={onCloseTextEdit} onOpenSectionLevel={onOpenSectionLevel} onSelect={onPlanSelect} onMarquee={onPlanMarquee}