diff --git a/packages/mtext-input-box/package.json b/packages/mtext-input-box/package.json index c5d5dde..8b58e0b 100644 --- a/packages/mtext-input-box/package.json +++ b/packages/mtext-input-box/package.json @@ -1,6 +1,6 @@ { "name": "@mlightcad/mtext-input-box", - "version": "0.2.9", + "version": "0.2.10", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/mtext-input-box/src/viewer/types.ts b/packages/mtext-input-box/src/viewer/types.ts index cc4a834..c983cf0 100644 --- a/packages/mtext-input-box/src/viewer/types.ts +++ b/packages/mtext-input-box/src/viewer/types.ts @@ -1,6 +1,7 @@ import type { Box, CursorStyle, SelectionStyle } from '@mlightcad/text-box-cursor'; import type { ColorSettings, + MTextAttachmentPoint, MTextColor, MTextParagraphAlignment, TextStyle @@ -194,6 +195,10 @@ export interface MTextInputBoxOptions { width: number; /** Optional world-space origin of the editor container. */ position?: THREE.Vector3; + /** + * Initial MTEXT attachment (DXF group 71). Defaults to top-left when omitted. + */ + initialAttachmentPoint?: MTextAttachmentPoint; /** * Default text style passed to `@mlightcad/mtext-renderer`. * diff --git a/packages/mtext-input-box/src/viewer/viewer.ts b/packages/mtext-input-box/src/viewer/viewer.ts index d212a85..1982f28 100644 --- a/packages/mtext-input-box/src/viewer/viewer.ts +++ b/packages/mtext-input-box/src/viewer/viewer.ts @@ -50,6 +50,26 @@ type HistorySnapshot = { currentFormat: CharFormat; }; +/** + * Local X of the MTEXT column's left edge relative to the insertion point (DXF 71), + * matching {@link MText.calculateAnchorPoint} / `mtext-renderer` frame semantics. + * Uses numeric codes so unit tests can partially mock `@mlightcad/mtext-renderer`. + */ +function attachmentColumnMinLocal( + width: number, + attachment: MTextAttachmentPoint | undefined +): number { + const w = Math.max(1, width); + const a = attachment as number | undefined; + // Left column: 1 TL, 4 ML, 7 BL, 10 baseline-left + if (a === undefined || a === 1 || a === 4 || a === 7 || a === 10) return 0; + // Center column: 2 TC, 5 MC, 8 BC, 11 baseline-center + if (a === 2 || a === 5 || a === 8 || a === 11) return -w / 2; + // Right column: 3 TR, 6 MR, 9 BR, 12 baseline-right + if (a === 3 || a === 6 || a === 9 || a === 12) return -w; + return 0; +} + /** * Three.js based MText editor component with core text editing interaction. */ @@ -294,6 +314,9 @@ export class MTextInputBox { this.colorSettings = options.colorSettings; this.width = Math.max(1, options.width); this.position = options.position?.clone() ?? new THREE.Vector3(0, 0, 0); + if (options.initialAttachmentPoint !== undefined) { + this.editorAttachmentPoint = options.initialAttachmentPoint; + } this.enableWordWrap = options.enableWordWrap ?? true; const textStyle = options.textStyle ?? MTextInputBox.DEFAULT_TEXT_STYLE; @@ -751,6 +774,42 @@ export class MTextInputBox { public setAttachmentPoint(attachmentPoint: string): void { const next = this.mapRibbonAttachmentCode(attachmentPoint); if (next === undefined || next === this.editorAttachmentPoint) return; + + // Keep the on-screen text frame fixed: DXF insertion point is the selected + // attachment location on the bounding box, so changing attachment must move + // `position` by the delta between the old and new anchor on the same box. + if (this.rendererReady && Number.isFinite(this.layoutContainer.width)) { + const left = this.position.x + this.layoutContainer.x; + const right = left + this.layoutContainer.width; + const bottom = this.position.y + this.layoutContainer.y; + const top = bottom + this.layoutContainer.height; + if ( + Number.isFinite(left) && + Number.isFinite(right) && + Number.isFinite(bottom) && + Number.isFinite(top) && + right >= left - 1e-9 && + top >= bottom - 1e-9 + ) { + const oldAnchor = this.computeAttachmentAnchorOnBounds( + left, + right, + bottom, + top, + this.editorAttachmentPoint + ); + const newAnchor = this.computeAttachmentAnchorOnBounds(left, right, bottom, top, next); + this.position.x += newAnchor.x - oldAnchor.x; + this.position.y += newAnchor.y - oldAnchor.y; + this.cursorRenderer.setViewTransform({ + x: this.position.x, + y: this.position.y, + scaleX: 1, + scaleY: 1 + }); + } + } + this.editorAttachmentPoint = next; this.relayout(); this.emit('change'); @@ -761,6 +820,11 @@ export class MTextInputBox { return this.attachmentPointToRibbonCode(this.editorAttachmentPoint); } + /** Returns the current attachment as an {@link MTextAttachmentPoint} value (DXF 71). */ + public getMTextAttachmentPoint(): MTextAttachmentPoint { + return this.editorAttachmentPoint; + } + /** Toggles selected alphabetic text between upper and lower case. */ public toggleCase(): void { const selection = this.getSelectionRange(); @@ -1466,6 +1530,60 @@ export class MTextInputBox { } this.syncStateFromCursor(); + + // Renderer-driven vertical bounds can omit a trailing empty row when a single + // inflated line strip overlaps all glyphs (we then keep only glyph extents). + // Cursor geometry for empty rows still carries those rows (break fallbacks), so + // union only empty lines — non-empty rows are already covered by glyph bounds, + // and their LineInfo can still reflect oversized strips (reintroducing leading). + const expanded = this.unionLayoutContainerWithCursorLines(this.layoutContainer); + if ( + Math.abs(expanded.y - this.layoutContainer.y) > 1e-6 || + Math.abs(expanded.height - this.layoutContainer.height) > 1e-6 + ) { + this.layoutContainer = expanded; + this.latestCursorLayoutData.containerBox = { ...this.layoutContainer }; + this.cursorLogic.updateData(this.layoutContainer, charBoxes, lineBreakIndices, lineLayouts); + this.cursorLogic.moveTo(nextIndex, pendingLineHint); + if (this.selectionStart !== this.selectionEnd) { + this.cursorLogic.setSelection(this.selectionStart, this.selectionEnd); + } else { + this.cursorLogic.clearSelection(); + } + this.syncStateFromCursor(); + } + } + + /** + * Extends {@link layoutContainer} vertically so empty logical rows (no glyphs) + * remain inside the chrome bounds. Intentionally ignores non-empty rows: their + * `LineInfo` may still use inflated renderer line strips, while + * {@link computeEditorVerticalBounds} already tightened using glyph boxes. + */ + private unionLayoutContainerWithCursorLines(container: Box): Box { + const lines = this.cursorLogic.getLines(); + if (lines.length === 0) return container; + + let low = container.y; + let high = container.y + container.height; + + for (const line of lines) { + if (line.charCount !== 0) continue; + const lineLo = line.y - line.height / 2; + const lineHi = line.y + line.height / 2; + if (Number.isFinite(lineLo)) low = Math.min(low, lineLo); + if (Number.isFinite(lineHi)) high = Math.max(high, lineHi); + } + + if (!Number.isFinite(low) || !Number.isFinite(high) || high < low) { + return container; + } + + return { + ...container, + y: low, + height: high - low + }; } /** @@ -1593,13 +1711,11 @@ export class MTextInputBox { ); const containerBox = { - x: local.x, + x: attachmentColumnMinLocal(this.width, this.editorAttachmentPoint), y: containerTop, - width: local.width, + width: Math.max(1, this.width), height: Math.max(0, containerBottom - containerTop) }; - containerBox.x = 0; - containerBox.width = this.width; const minHeight = this.getFallbackLineAdvance(); if (containerBox.height < minHeight) { const delta = minHeight - containerBox.height; @@ -1691,7 +1807,7 @@ export class MTextInputBox { return { containerBox: { - x: 0, + x: attachmentColumnMinLocal(this.width, this.editorAttachmentPoint), y: minY, width: this.width, height: Math.max(1, -minY) @@ -2831,6 +2947,47 @@ export class MTextInputBox { return new MTextDocument(normalizedAst); } + /** + * World-space point on the layout bounds that matches the given MTEXT + * attachment (DXF 71), using the same box convention as {@link updateBoundingBoxGeometry}. + */ + private computeAttachmentAnchorOnBounds( + left: number, + right: number, + bottom: number, + top: number, + point: MTextAttachmentPoint + ): THREE.Vector3 { + const midX = (left + right) * 0.5; + const midY = (bottom + top) * 0.5; + const z = this.position.z; + switch (point) { + case MTextAttachmentPoint.TopLeft: + return new THREE.Vector3(left, top, z); + case MTextAttachmentPoint.TopCenter: + return new THREE.Vector3(midX, top, z); + case MTextAttachmentPoint.TopRight: + return new THREE.Vector3(right, top, z); + case MTextAttachmentPoint.MiddleLeft: + return new THREE.Vector3(left, midY, z); + case MTextAttachmentPoint.MiddleCenter: + return new THREE.Vector3(midX, midY, z); + case MTextAttachmentPoint.MiddleRight: + return new THREE.Vector3(right, midY, z); + case MTextAttachmentPoint.BottomLeft: + case MTextAttachmentPoint.BaselineLeft: + return new THREE.Vector3(left, bottom, z); + case MTextAttachmentPoint.BottomCenter: + case MTextAttachmentPoint.BaselineCenter: + return new THREE.Vector3(midX, bottom, z); + case MTextAttachmentPoint.BottomRight: + case MTextAttachmentPoint.BaselineRight: + return new THREE.Vector3(right, bottom, z); + default: + return new THREE.Vector3(left, top, z); + } + } + private mapRibbonParagraphAlignment(alignment: string): MTextParagraphAlignment | undefined { switch (alignment) { case 'default': diff --git a/packages/mtext-input-box/tests/viewer.mapping.test.ts b/packages/mtext-input-box/tests/viewer.mapping.test.ts index 62d913c..a7bb351 100644 --- a/packages/mtext-input-box/tests/viewer.mapping.test.ts +++ b/packages/mtext-input-box/tests/viewer.mapping.test.ts @@ -14,18 +14,13 @@ vi.mock('@mlightcad/text-box-cursor', () => { vi.mock('@mlightcad/mtext-renderer', () => { class UnifiedRenderer {} + class MTextContext {} return { getColorByIndex: () => 0xffffff, UnifiedRenderer, - MTextAttachmentPoint: { TopLeft: 1 }, - MTextFlowDirection: { LEFT_TO_RIGHT: 1 } - }; -}); - -vi.mock('@mlightcad/mtext-renderer', () => { - class MTextContext {} - return { MTextContext, + MTextAttachmentPoint: { TopLeft: 1 }, + MTextFlowDirection: { LEFT_TO_RIGHT: 1 }, MTextLineAlignment: { TOP: 1, MIDDLE: 2, @@ -307,6 +302,43 @@ describe('MTextInputBox cursor/document index mapping', () => { }); }); + test('MTEXT column box shifts left for top-right attachment (DXF 71)', () => { + const proto = MTextInputBox.prototype as unknown as Record any>; + const context = { + position: { x: 200, y: 20 }, + width: 120, + editorAttachmentPoint: 3, + getFallbackLineAdvance: () => 16, + toLocalBox: proto.toLocalBox, + computeEditorVerticalBounds: proto.computeEditorVerticalBounds + }; + + const object = { + box: new THREE.Box3(new THREE.Vector3(100, 14, 0), new THREE.Vector3(200, 30, 0)), + createLayoutData: () => ({ + chars: [ + { + type: 'CHAR', + box: new THREE.Box3(new THREE.Vector3(190, 20, 0), new THREE.Vector3(200, 30, 0)), + char: 'A', + children: [] + } + ], + lines: [{ y: 25, height: 10, breakIndex: undefined }] + }) + }; + + const extractBoxesFromRenderedObject = proto.extractBoxesFromRenderedObject as ( + this: any, + obj: any + ) => { containerBox: { x: number; y: number; width: number; height: number } }; + + const result = extractBoxesFromRenderedObject.call(context, object); + + expect(result.containerBox.x).toBe(-120); + expect(result.containerBox.width).toBe(120); + }); + test('vertical container ignores inflated object.box when layout lines/chars exist', () => { const proto = MTextInputBox.prototype as unknown as Record any>; const context = { @@ -507,6 +539,50 @@ describe('MTextInputBox cursor/document index mapping', () => { expect(result.height).toBeCloseTo(24); }); + test('unionLayoutContainerWithCursorLines extends bounds for trailing empty row', () => { + const proto = MTextInputBox.prototype as unknown as Record any>; + const unionLayoutContainerWithCursorLines = proto.unionLayoutContainerWithCursorLines as ( + this: any, + container: { x: number; y: number; width: number; height: number } + ) => { x: number; y: number; width: number; height: number }; + + const context = { + cursorLogic: { + getLines: () => [ + { startIndex: 0, endIndex: 0, charCount: 1, y: 0, height: 10 }, + { startIndex: 1, endIndex: 0, charCount: 0, y: -15, height: 10 } + ] + } + }; + + const container = { x: 0, y: -5, width: 120, height: 10 }; + const result = unionLayoutContainerWithCursorLines.call(context, container); + + expect(result).toEqual({ x: 0, y: -20, width: 120, height: 25 }); + }); + + test('unionLayoutContainerWithCursorLines ignores non-empty lines (oversized strips)', () => { + const proto = MTextInputBox.prototype as unknown as Record any>; + const unionLayoutContainerWithCursorLines = proto.unionLayoutContainerWithCursorLines as ( + this: any, + container: { x: number; y: number; width: number; height: number } + ) => { x: number; y: number; width: number; height: number }; + + const context = { + cursorLogic: { + getLines: () => [ + { startIndex: 0, endIndex: 0, charCount: 1, y: 0, height: 100 }, + { startIndex: 1, endIndex: 0, charCount: 0, y: -55, height: 10 } + ] + } + }; + + const container = { x: 0, y: -5, width: 120, height: 10 }; + const result = unionLayoutContainerWithCursorLines.call(context, container); + + expect(result).toEqual({ x: 0, y: -60, width: 120, height: 65 }); + }); + test('handleKeyDown closes editor on Escape', () => { const proto = MTextInputBox.prototype as unknown as Record any>; const handleKeyDown = proto.handleKeyDown as (this: any, event: KeyboardEvent) => boolean;