diff --git a/frontend/src/plugins/builtin/core-annotate/index.ts b/frontend/src/plugins/builtin/core-annotate/index.ts index dcd84ad..b303456 100644 --- a/frontend/src/plugins/builtin/core-annotate/index.ts +++ b/frontend/src/plugins/builtin/core-annotate/index.ts @@ -127,15 +127,81 @@ export const dimensionTool: ToolExtensionV2 = make2PointAnnotate('dimension', 'B export const leaderTool: ToolExtensionV2 = make2PointAnnotate('leader', 'Leader', '\u21b1', buildLeader); /** V2-Plugin: Beschriftung/Bemaßung (A4.4). */ +// ───────────────────────────────────────────────────────────── +// Task D4: MTEXT — Mehrzeiliger Text mit optionalem Rahmen. +// Klick platziert Text (Inhalt aus Options, Zeilenumbrüche erhalten), +// frame-Option erzeugt ein rect in derselben Transaktion (1 Undo). +// ───────────────────────────────────────────────────────────── + +export const mtextTool: ToolExtensionV2 = { + manifest: { + id: 'mtext', + label: 'Mehrzeilentext', + icon: '¶', + ribbonTab: 'format', + tags: ['basic'], + description: 'Mehrzeiligen Text platzieren (Rahmen optional, Task D4)', + }, + optionsSchema: [ + { key: 'text', label: 'Text', type: 'text' }, + { key: 'fontSize', label: 'Größe', type: 'number', min: 6, max: 72 }, + { key: 'frame', label: 'Rahmen', type: 'checkbox' }, + ], + handlers: { + down(e, ctx) { + const c = ctx as FullToolContext; + if (!c.doc) return; + const layerId = (c.options.layerId as string | undefined) ?? 'layer-0'; + const text = (c.options.text as string | undefined) ?? ''; + const fontSize = (c.options.fontSize as number | undefined) ?? 12; + const frame = (c.options.frame as boolean | undefined) ?? false; + // Newline ohne Escape-Probleme: charCode 10 + const NL = String.fromCharCode(10); + const lines = text.split(NL); + const lineCount = Math.max(1, lines.length); + const maxLen = lines.reduce((m, l) => Math.max(m, l.length), 0); + const textW = maxLen * fontSize * 0.6; + const textH = lineCount * fontSize * 1.4; + const els: CADElement[] = [{ + id: uid('mtext'), + type: 'text', + layerId, + x: e.world.x, + y: e.world.y, + width: textW, + height: textH, + properties: { text, fontSize, mtext: true }, + } as unknown as CADElement]; + if (frame) { + const pad = fontSize * 0.4; + els.push({ + id: uid('mtextframe'), + type: 'rect', + layerId, + x: e.world.x, + y: e.world.y, + width: textW + pad * 2, + height: textH + pad * 2, + properties: { stroke: '#9aa0a6', strokeWidth: 1, fill: 'none' }, + } as unknown as CADElement); + } + c.doc.transact(() => { + for (const el of els) c.doc!.addElement(el); + }); + ctx.setStatus(`Mehrzeilentext platziert (${lineCount} Zeile(n)${frame ? ', mit Rahmen' : ''})`); + }, + }, +}; + export const coreAnnotatePlugin: PluginV2 = { manifest: { id: 'core-annotate', name: 'Beschriftung (Kern)', - version: '1.0.0', + version: '1.1.0', author: 'web-cad team', description: 'Text, Bemaßung, Leader (V2).', category: 'tools', enabledByDefault: true, }, - tools: [textTool, dimensionTool, leaderTool], + tools: [textTool, dimensionTool, leaderTool, mtextTool], }; diff --git a/frontend/tests/mtextTool.test.ts b/frontend/tests/mtextTool.test.ts new file mode 100644 index 0000000..eaf37e5 --- /dev/null +++ b/frontend/tests/mtextTool.test.ts @@ -0,0 +1,101 @@ +/** + * Task D4 – MTEXT-Tool: Mehrzeiliger Text mit/ohne Rahmen. + * + * Klick platziert mehrzeiligen Text (Inhalt aus Options, Zeilen- + * umbrueche), fontSize-Option, optionale Rahmen-Option erzeugt ein + * rect-Element in derselben Transaktion (1 Undo-Schritt). + */ +import { describe, it, expect } from 'vitest'; +import { mtextTool } from '../src/plugins/builtin/core-annotate'; +import { CADDocument } from '../src/kernel/document/CADDocument'; +import { createInMemoryDoc } from './helpers/inMemoryDoc'; +import type { CADElement, Pt } from '../src/types/cad.types'; + +function mkCtx(options: Record = {}) { + const { ydoc } = createInMemoryDoc(); + const doc = new CADDocument(ydoc); + const statuses: string[] = []; + const ctx: any = { + doc, + options, + setStatus: (m: string) => statuses.push(m), + setPreview: (_el: CADElement | null) => {}, + }; + return { ctx, doc, statuses }; +} + +function pe(x: number, y: number): { world: Pt } { + return { world: { x, y } } as never; +} + +describe('D4: mtextTool', () => { + it('platziert mehrzeiligen Text mit Zeilenumbruechen aus den Options', () => { + const { ctx, doc } = mkCtx({ text: 'Zeile 1\nZeile 2\nZeile 3', fontSize: 14 }); + mtextTool.handlers.down!(pe(100, 50), ctx); + const els = doc.getAllElements(); + expect(els).toHaveLength(1); + const el = els[0]; + expect(el.type).toBe('text'); + expect(el.x).toBe(100); + expect(el.y).toBe(50); + expect(el.properties.text).toBe('Zeile 1\nZeile 2\nZeile 3'); + expect(el.properties.fontSize).toBe(14); + expect(el.properties.mtext).toBe(true); + }); + + it('Rahmen-Option erzeugt zusaetzliches rect in derselben Transaktion (1 Undo)', () => { + const { ctx, doc } = mkCtx({ text: 'Mit Rahmen', frame: true }); + mtextTool.handlers.down!(pe(0, 0), ctx); + const els = doc.getAllElements(); + expect(els).toHaveLength(2); + const texts = els.filter((e) => e.type === 'text'); + const rects = els.filter((e) => e.type === 'rect'); + expect(texts).toHaveLength(1); + expect(rects).toHaveLength(1); + expect(rects[0].x).toBeCloseTo(0, 5); + expect(rects[0].y).toBeCloseTo(0, 5); + doc.undo(); + expect(doc.getAllElements()).toHaveLength(0); + }); + + it('ohne frame-Option: nur Text, kein rect', () => { + const { ctx, doc } = mkCtx({ text: 'Ohne Rahmen' }); + mtextTool.handlers.down!(pe(0, 0), ctx); + expect(doc.getAllElements().filter((e) => e.type === 'rect')).toHaveLength(0); + }); + + it('Standard-Text wenn Options leer: MTEXT-Platzhalter leer (Editor bearbeitet spaeter)', () => { + const { ctx, doc } = mkCtx({}); + mtextTool.handlers.down!(pe(10, 10), ctx); + const el = doc.getAllElements()[0]; + expect(el.properties.text).toBe(''); + expect(el.properties.fontSize).toBe(12); // Default + }); + + it('OptionsSchema: text/number/checkbox-Felder', () => { + const schema = mtextTool.optionsSchema ?? []; + const keys = schema.map((f) => f.key); + expect(keys).toContain('text'); + expect(keys).toContain('fontSize'); + expect(keys).toContain('frame'); + const textField = schema.find((f) => f.key === 'text'); + expect(textField!.type).toBe('text'); + const frameField = schema.find((f) => f.key === 'frame'); + expect(frameField!.type).toBe('checkbox'); + }); + + it('Rahmenhoehe waechst mit Zeilenzahl', () => { + const { ctx: ctx1, doc: doc1 } = mkCtx({ text: 'eine Zeile', frame: true }); + mtextTool.handlers.down!(pe(0, 0), ctx1); + const { ctx: ctx3, doc: doc3 } = mkCtx({ text: 'eins\nzwei\ndrei', frame: true }); + mtextTool.handlers.down!(pe(0, 0), ctx3); + const rect1 = doc1.getAllElements().find((e) => e.type === 'rect')!; + const rect3 = doc3.getAllElements().find((e) => e.type === 'rect')!; + expect(rect3.height).toBeGreaterThan(rect1.height); + }); + + it('Manifest: id=mtext, tags basic', () => { + expect(mtextTool.manifest.id).toBe('mtext'); + expect((mtextTool.manifest.tags ?? [])).toContain('basic'); + }); +});