@@ -50,6 +50,26 @@ type HistorySnapshot = {
5050 currentFormat : CharFormat ;
5151} ;
5252
53+ /**
54+ * Local X of the MTEXT column's left edge relative to the insertion point (DXF 71),
55+ * matching {@link MText.calculateAnchorPoint} / `mtext-renderer` frame semantics.
56+ * Uses numeric codes so unit tests can partially mock `@mlightcad/mtext-renderer`.
57+ */
58+ function attachmentColumnMinLocal (
59+ width : number ,
60+ attachment : MTextAttachmentPoint | undefined
61+ ) : number {
62+ const w = Math . max ( 1 , width ) ;
63+ const a = attachment as number | undefined ;
64+ // Left column: 1 TL, 4 ML, 7 BL, 10 baseline-left
65+ if ( a === undefined || a === 1 || a === 4 || a === 7 || a === 10 ) return 0 ;
66+ // Center column: 2 TC, 5 MC, 8 BC, 11 baseline-center
67+ if ( a === 2 || a === 5 || a === 8 || a === 11 ) return - w / 2 ;
68+ // Right column: 3 TR, 6 MR, 9 BR, 12 baseline-right
69+ if ( a === 3 || a === 6 || a === 9 || a === 12 ) return - w ;
70+ return 0 ;
71+ }
72+
5373/**
5474 * Three.js based MText editor component with core text editing interaction.
5575 */
@@ -294,6 +314,9 @@ export class MTextInputBox {
294314 this . colorSettings = options . colorSettings ;
295315 this . width = Math . max ( 1 , options . width ) ;
296316 this . position = options . position ?. clone ( ) ?? new THREE . Vector3 ( 0 , 0 , 0 ) ;
317+ if ( options . initialAttachmentPoint !== undefined ) {
318+ this . editorAttachmentPoint = options . initialAttachmentPoint ;
319+ }
297320 this . enableWordWrap = options . enableWordWrap ?? true ;
298321
299322 const textStyle = options . textStyle ?? MTextInputBox . DEFAULT_TEXT_STYLE ;
@@ -751,6 +774,42 @@ export class MTextInputBox {
751774 public setAttachmentPoint ( attachmentPoint : string ) : void {
752775 const next = this . mapRibbonAttachmentCode ( attachmentPoint ) ;
753776 if ( next === undefined || next === this . editorAttachmentPoint ) return ;
777+
778+ // Keep the on-screen text frame fixed: DXF insertion point is the selected
779+ // attachment location on the bounding box, so changing attachment must move
780+ // `position` by the delta between the old and new anchor on the same box.
781+ if ( this . rendererReady && Number . isFinite ( this . layoutContainer . width ) ) {
782+ const left = this . position . x + this . layoutContainer . x ;
783+ const right = left + this . layoutContainer . width ;
784+ const bottom = this . position . y + this . layoutContainer . y ;
785+ const top = bottom + this . layoutContainer . height ;
786+ if (
787+ Number . isFinite ( left ) &&
788+ Number . isFinite ( right ) &&
789+ Number . isFinite ( bottom ) &&
790+ Number . isFinite ( top ) &&
791+ right >= left - 1e-9 &&
792+ top >= bottom - 1e-9
793+ ) {
794+ const oldAnchor = this . computeAttachmentAnchorOnBounds (
795+ left ,
796+ right ,
797+ bottom ,
798+ top ,
799+ this . editorAttachmentPoint
800+ ) ;
801+ const newAnchor = this . computeAttachmentAnchorOnBounds ( left , right , bottom , top , next ) ;
802+ this . position . x += newAnchor . x - oldAnchor . x ;
803+ this . position . y += newAnchor . y - oldAnchor . y ;
804+ this . cursorRenderer . setViewTransform ( {
805+ x : this . position . x ,
806+ y : this . position . y ,
807+ scaleX : 1 ,
808+ scaleY : 1
809+ } ) ;
810+ }
811+ }
812+
754813 this . editorAttachmentPoint = next ;
755814 this . relayout ( ) ;
756815 this . emit ( 'change' ) ;
@@ -761,6 +820,11 @@ export class MTextInputBox {
761820 return this . attachmentPointToRibbonCode ( this . editorAttachmentPoint ) ;
762821 }
763822
823+ /** Returns the current attachment as an {@link MTextAttachmentPoint} value (DXF 71). */
824+ public getMTextAttachmentPoint ( ) : MTextAttachmentPoint {
825+ return this . editorAttachmentPoint ;
826+ }
827+
764828 /** Toggles selected alphabetic text between upper and lower case. */
765829 public toggleCase ( ) : void {
766830 const selection = this . getSelectionRange ( ) ;
@@ -1466,6 +1530,60 @@ export class MTextInputBox {
14661530 }
14671531
14681532 this . syncStateFromCursor ( ) ;
1533+
1534+ // Renderer-driven vertical bounds can omit a trailing empty row when a single
1535+ // inflated line strip overlaps all glyphs (we then keep only glyph extents).
1536+ // Cursor geometry for empty rows still carries those rows (break fallbacks), so
1537+ // union only empty lines — non-empty rows are already covered by glyph bounds,
1538+ // and their LineInfo can still reflect oversized strips (reintroducing leading).
1539+ const expanded = this . unionLayoutContainerWithCursorLines ( this . layoutContainer ) ;
1540+ if (
1541+ Math . abs ( expanded . y - this . layoutContainer . y ) > 1e-6 ||
1542+ Math . abs ( expanded . height - this . layoutContainer . height ) > 1e-6
1543+ ) {
1544+ this . layoutContainer = expanded ;
1545+ this . latestCursorLayoutData . containerBox = { ...this . layoutContainer } ;
1546+ this . cursorLogic . updateData ( this . layoutContainer , charBoxes , lineBreakIndices , lineLayouts ) ;
1547+ this . cursorLogic . moveTo ( nextIndex , pendingLineHint ) ;
1548+ if ( this . selectionStart !== this . selectionEnd ) {
1549+ this . cursorLogic . setSelection ( this . selectionStart , this . selectionEnd ) ;
1550+ } else {
1551+ this . cursorLogic . clearSelection ( ) ;
1552+ }
1553+ this . syncStateFromCursor ( ) ;
1554+ }
1555+ }
1556+
1557+ /**
1558+ * Extends {@link layoutContainer} vertically so empty logical rows (no glyphs)
1559+ * remain inside the chrome bounds. Intentionally ignores non-empty rows: their
1560+ * `LineInfo` may still use inflated renderer line strips, while
1561+ * {@link computeEditorVerticalBounds} already tightened using glyph boxes.
1562+ */
1563+ private unionLayoutContainerWithCursorLines ( container : Box ) : Box {
1564+ const lines = this . cursorLogic . getLines ( ) ;
1565+ if ( lines . length === 0 ) return container ;
1566+
1567+ let low = container . y ;
1568+ let high = container . y + container . height ;
1569+
1570+ for ( const line of lines ) {
1571+ if ( line . charCount !== 0 ) continue ;
1572+ const lineLo = line . y - line . height / 2 ;
1573+ const lineHi = line . y + line . height / 2 ;
1574+ if ( Number . isFinite ( lineLo ) ) low = Math . min ( low , lineLo ) ;
1575+ if ( Number . isFinite ( lineHi ) ) high = Math . max ( high , lineHi ) ;
1576+ }
1577+
1578+ if ( ! Number . isFinite ( low ) || ! Number . isFinite ( high ) || high < low ) {
1579+ return container ;
1580+ }
1581+
1582+ return {
1583+ ...container ,
1584+ y : low ,
1585+ height : high - low
1586+ } ;
14691587 }
14701588
14711589 /**
@@ -1593,13 +1711,11 @@ export class MTextInputBox {
15931711 ) ;
15941712
15951713 const containerBox = {
1596- x : local . x ,
1714+ x : attachmentColumnMinLocal ( this . width , this . editorAttachmentPoint ) ,
15971715 y : containerTop ,
1598- width : local . width ,
1716+ width : Math . max ( 1 , this . width ) ,
15991717 height : Math . max ( 0 , containerBottom - containerTop )
16001718 } ;
1601- containerBox . x = 0 ;
1602- containerBox . width = this . width ;
16031719 const minHeight = this . getFallbackLineAdvance ( ) ;
16041720 if ( containerBox . height < minHeight ) {
16051721 const delta = minHeight - containerBox . height ;
@@ -1691,7 +1807,7 @@ export class MTextInputBox {
16911807
16921808 return {
16931809 containerBox : {
1694- x : 0 ,
1810+ x : attachmentColumnMinLocal ( this . width , this . editorAttachmentPoint ) ,
16951811 y : minY ,
16961812 width : this . width ,
16971813 height : Math . max ( 1 , - minY )
@@ -2831,6 +2947,47 @@ export class MTextInputBox {
28312947 return new MTextDocument ( normalizedAst ) ;
28322948 }
28332949
2950+ /**
2951+ * World-space point on the layout bounds that matches the given MTEXT
2952+ * attachment (DXF 71), using the same box convention as {@link updateBoundingBoxGeometry}.
2953+ */
2954+ private computeAttachmentAnchorOnBounds (
2955+ left : number ,
2956+ right : number ,
2957+ bottom : number ,
2958+ top : number ,
2959+ point : MTextAttachmentPoint
2960+ ) : THREE . Vector3 {
2961+ const midX = ( left + right ) * 0.5 ;
2962+ const midY = ( bottom + top ) * 0.5 ;
2963+ const z = this . position . z ;
2964+ switch ( point ) {
2965+ case MTextAttachmentPoint . TopLeft :
2966+ return new THREE . Vector3 ( left , top , z ) ;
2967+ case MTextAttachmentPoint . TopCenter :
2968+ return new THREE . Vector3 ( midX , top , z ) ;
2969+ case MTextAttachmentPoint . TopRight :
2970+ return new THREE . Vector3 ( right , top , z ) ;
2971+ case MTextAttachmentPoint . MiddleLeft :
2972+ return new THREE . Vector3 ( left , midY , z ) ;
2973+ case MTextAttachmentPoint . MiddleCenter :
2974+ return new THREE . Vector3 ( midX , midY , z ) ;
2975+ case MTextAttachmentPoint . MiddleRight :
2976+ return new THREE . Vector3 ( right , midY , z ) ;
2977+ case MTextAttachmentPoint . BottomLeft :
2978+ case MTextAttachmentPoint . BaselineLeft :
2979+ return new THREE . Vector3 ( left , bottom , z ) ;
2980+ case MTextAttachmentPoint . BottomCenter :
2981+ case MTextAttachmentPoint . BaselineCenter :
2982+ return new THREE . Vector3 ( midX , bottom , z ) ;
2983+ case MTextAttachmentPoint . BottomRight :
2984+ case MTextAttachmentPoint . BaselineRight :
2985+ return new THREE . Vector3 ( right , bottom , z ) ;
2986+ default :
2987+ return new THREE . Vector3 ( left , top , z ) ;
2988+ }
2989+ }
2990+
28342991 private mapRibbonParagraphAlignment ( alignment : string ) : MTextParagraphAlignment | undefined {
28352992 switch ( alignment ) {
28362993 case 'default' :
0 commit comments