Skip to content

Commit 3ec3533

Browse files
authored
feat: DXF attachment anchoring, stable attachment switching, and empty-line layout bounds (#11)
1 parent bdb685c commit 3ec3533

4 files changed

Lines changed: 252 additions & 14 deletions

File tree

packages/mtext-input-box/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@mlightcad/mtext-input-box",
3-
"version": "0.2.9",
3+
"version": "0.2.10",
44
"type": "module",
55
"main": "./dist/index.js",
66
"types": "./dist/index.d.ts",

packages/mtext-input-box/src/viewer/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Box, CursorStyle, SelectionStyle } from '@mlightcad/text-box-cursor';
22
import type {
33
ColorSettings,
4+
MTextAttachmentPoint,
45
MTextColor,
56
MTextParagraphAlignment,
67
TextStyle
@@ -194,6 +195,10 @@ export interface MTextInputBoxOptions {
194195
width: number;
195196
/** Optional world-space origin of the editor container. */
196197
position?: THREE.Vector3;
198+
/**
199+
* Initial MTEXT attachment (DXF group 71). Defaults to top-left when omitted.
200+
*/
201+
initialAttachmentPoint?: MTextAttachmentPoint;
197202
/**
198203
* Default text style passed to `@mlightcad/mtext-renderer`.
199204
*

packages/mtext-input-box/src/viewer/viewer.ts

Lines changed: 162 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -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':

packages/mtext-input-box/tests/viewer.mapping.test.ts

Lines changed: 84 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,13 @@ vi.mock('@mlightcad/text-box-cursor', () => {
1414

1515
vi.mock('@mlightcad/mtext-renderer', () => {
1616
class UnifiedRenderer {}
17+
class MTextContext {}
1718
return {
1819
getColorByIndex: () => 0xffffff,
1920
UnifiedRenderer,
20-
MTextAttachmentPoint: { TopLeft: 1 },
21-
MTextFlowDirection: { LEFT_TO_RIGHT: 1 }
22-
};
23-
});
24-
25-
vi.mock('@mlightcad/mtext-renderer', () => {
26-
class MTextContext {}
27-
return {
2821
MTextContext,
22+
MTextAttachmentPoint: { TopLeft: 1 },
23+
MTextFlowDirection: { LEFT_TO_RIGHT: 1 },
2924
MTextLineAlignment: {
3025
TOP: 1,
3126
MIDDLE: 2,
@@ -307,6 +302,43 @@ describe('MTextInputBox cursor/document index mapping', () => {
307302
});
308303
});
309304

305+
test('MTEXT column box shifts left for top-right attachment (DXF 71)', () => {
306+
const proto = MTextInputBox.prototype as unknown as Record<string, (...args: any[]) => any>;
307+
const context = {
308+
position: { x: 200, y: 20 },
309+
width: 120,
310+
editorAttachmentPoint: 3,
311+
getFallbackLineAdvance: () => 16,
312+
toLocalBox: proto.toLocalBox,
313+
computeEditorVerticalBounds: proto.computeEditorVerticalBounds
314+
};
315+
316+
const object = {
317+
box: new THREE.Box3(new THREE.Vector3(100, 14, 0), new THREE.Vector3(200, 30, 0)),
318+
createLayoutData: () => ({
319+
chars: [
320+
{
321+
type: 'CHAR',
322+
box: new THREE.Box3(new THREE.Vector3(190, 20, 0), new THREE.Vector3(200, 30, 0)),
323+
char: 'A',
324+
children: []
325+
}
326+
],
327+
lines: [{ y: 25, height: 10, breakIndex: undefined }]
328+
})
329+
};
330+
331+
const extractBoxesFromRenderedObject = proto.extractBoxesFromRenderedObject as (
332+
this: any,
333+
obj: any
334+
) => { containerBox: { x: number; y: number; width: number; height: number } };
335+
336+
const result = extractBoxesFromRenderedObject.call(context, object);
337+
338+
expect(result.containerBox.x).toBe(-120);
339+
expect(result.containerBox.width).toBe(120);
340+
});
341+
310342
test('vertical container ignores inflated object.box when layout lines/chars exist', () => {
311343
const proto = MTextInputBox.prototype as unknown as Record<string, (...args: any[]) => any>;
312344
const context = {
@@ -507,6 +539,50 @@ describe('MTextInputBox cursor/document index mapping', () => {
507539
expect(result.height).toBeCloseTo(24);
508540
});
509541

542+
test('unionLayoutContainerWithCursorLines extends bounds for trailing empty row', () => {
543+
const proto = MTextInputBox.prototype as unknown as Record<string, (...args: any[]) => any>;
544+
const unionLayoutContainerWithCursorLines = proto.unionLayoutContainerWithCursorLines as (
545+
this: any,
546+
container: { x: number; y: number; width: number; height: number }
547+
) => { x: number; y: number; width: number; height: number };
548+
549+
const context = {
550+
cursorLogic: {
551+
getLines: () => [
552+
{ startIndex: 0, endIndex: 0, charCount: 1, y: 0, height: 10 },
553+
{ startIndex: 1, endIndex: 0, charCount: 0, y: -15, height: 10 }
554+
]
555+
}
556+
};
557+
558+
const container = { x: 0, y: -5, width: 120, height: 10 };
559+
const result = unionLayoutContainerWithCursorLines.call(context, container);
560+
561+
expect(result).toEqual({ x: 0, y: -20, width: 120, height: 25 });
562+
});
563+
564+
test('unionLayoutContainerWithCursorLines ignores non-empty lines (oversized strips)', () => {
565+
const proto = MTextInputBox.prototype as unknown as Record<string, (...args: any[]) => any>;
566+
const unionLayoutContainerWithCursorLines = proto.unionLayoutContainerWithCursorLines as (
567+
this: any,
568+
container: { x: number; y: number; width: number; height: number }
569+
) => { x: number; y: number; width: number; height: number };
570+
571+
const context = {
572+
cursorLogic: {
573+
getLines: () => [
574+
{ startIndex: 0, endIndex: 0, charCount: 1, y: 0, height: 100 },
575+
{ startIndex: 1, endIndex: 0, charCount: 0, y: -55, height: 10 }
576+
]
577+
}
578+
};
579+
580+
const container = { x: 0, y: -5, width: 120, height: 10 };
581+
const result = unionLayoutContainerWithCursorLines.call(context, container);
582+
583+
expect(result).toEqual({ x: 0, y: -60, width: 120, height: 65 });
584+
});
585+
510586
test('handleKeyDown closes editor on Escape', () => {
511587
const proto = MTextInputBox.prototype as unknown as Record<string, (...args: any[]) => any>;
512588
const handleKeyDown = proto.handleKeyDown as (this: any, event: KeyboardEvent) => boolean;

0 commit comments

Comments
 (0)