Skip to content

Commit 3bba6b1

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

6 files changed

Lines changed: 2104 additions & 636 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.11",
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: 229 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,29 @@ 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+
73+
/** Marks MTEXT roots added by {@link MTextInputBox} so stray scene objects can be scavenged. */
74+
const MTEXT_INPUT_BOX_SCENE_ROOT_KEY = '__mlightcadMTextInputBoxSceneRoot';
75+
5376
/**
5477
* Three.js based MText editor component with core text editing interaction.
5578
*/
@@ -294,6 +317,9 @@ export class MTextInputBox {
294317
this.colorSettings = options.colorSettings;
295318
this.width = Math.max(1, options.width);
296319
this.position = options.position?.clone() ?? new THREE.Vector3(0, 0, 0);
320+
if (options.initialAttachmentPoint !== undefined) {
321+
this.editorAttachmentPoint = options.initialAttachmentPoint;
322+
}
297323
this.enableWordWrap = options.enableWordWrap ?? true;
298324

299325
const textStyle = options.textStyle ?? MTextInputBox.DEFAULT_TEXT_STYLE;
@@ -751,6 +777,50 @@ export class MTextInputBox {
751777
public setAttachmentPoint(attachmentPoint: string): void {
752778
const next = this.mapRibbonAttachmentCode(attachmentPoint);
753779
if (next === undefined || next === this.editorAttachmentPoint) return;
780+
781+
// Keep the on-screen text frame fixed: DXF insertion point is the selected
782+
// attachment location on the bounding box, so changing attachment must move
783+
// `position` by the delta between the old and new anchor on the same box.
784+
//
785+
// Do not gate this on `rendererReady`: while fonts load, `relayout()` still
786+
// fills `layoutContainer` from the fallback layout. Skipping the move here
787+
// left `position` at the top-left anchor even after the user chose middle
788+
// center, so committed MTEXT used the wrong insertion vs attachment (DXF 71).
789+
if (
790+
Number.isFinite(this.layoutContainer.width) &&
791+
Number.isFinite(this.layoutContainer.height)
792+
) {
793+
const left = this.position.x + this.layoutContainer.x;
794+
const right = left + this.layoutContainer.width;
795+
const bottom = this.position.y + this.layoutContainer.y;
796+
const top = bottom + this.layoutContainer.height;
797+
if (
798+
Number.isFinite(left) &&
799+
Number.isFinite(right) &&
800+
Number.isFinite(bottom) &&
801+
Number.isFinite(top) &&
802+
right >= left - 1e-9 &&
803+
top >= bottom - 1e-9
804+
) {
805+
const oldAnchor = this.computeAttachmentAnchorOnBounds(
806+
left,
807+
right,
808+
bottom,
809+
top,
810+
this.editorAttachmentPoint
811+
);
812+
const newAnchor = this.computeAttachmentAnchorOnBounds(left, right, bottom, top, next);
813+
this.position.x += newAnchor.x - oldAnchor.x;
814+
this.position.y += newAnchor.y - oldAnchor.y;
815+
this.cursorRenderer.setViewTransform({
816+
x: this.position.x,
817+
y: this.position.y,
818+
scaleX: 1,
819+
scaleY: 1
820+
});
821+
}
822+
}
823+
754824
this.editorAttachmentPoint = next;
755825
this.relayout();
756826
this.emit('change');
@@ -761,6 +831,11 @@ export class MTextInputBox {
761831
return this.attachmentPointToRibbonCode(this.editorAttachmentPoint);
762832
}
763833

834+
/** Returns the current attachment as an {@link MTextAttachmentPoint} value (DXF 71). */
835+
public getMTextAttachmentPoint(): MTextAttachmentPoint {
836+
return this.editorAttachmentPoint;
837+
}
838+
764839
/** Toggles selected alphabetic text between upper and lower case. */
765840
public toggleCase(): void {
766841
const selection = this.getSelectionRange();
@@ -1370,6 +1445,10 @@ export class MTextInputBox {
13701445
const style = this.createTextStyle();
13711446
const colorSettings = this.resolveRenderColorSettings();
13721447
const object = this.mtextRenderer.syncRenderMText(mtextData, style, colorSettings);
1448+
// `MText.syncDraw()` appends a fresh layout group without clearing prior children.
1449+
// If the renderer ever reuses one root for multiple draws, prune stale roots so
1450+
// attachment / justify changes cannot leave duplicate glyph trees in the scene.
1451+
this.pruneExtraMTextLayoutRoots(object);
13731452

13741453
this.replaceRenderedObject(object);
13751454

@@ -1466,6 +1545,60 @@ export class MTextInputBox {
14661545
}
14671546

14681547
this.syncStateFromCursor();
1548+
1549+
// Renderer-driven vertical bounds can omit a trailing empty row when a single
1550+
// inflated line strip overlaps all glyphs (we then keep only glyph extents).
1551+
// Cursor geometry for empty rows still carries those rows (break fallbacks), so
1552+
// union only empty lines — non-empty rows are already covered by glyph bounds,
1553+
// and their LineInfo can still reflect oversized strips (reintroducing leading).
1554+
const expanded = this.unionLayoutContainerWithCursorLines(this.layoutContainer);
1555+
if (
1556+
Math.abs(expanded.y - this.layoutContainer.y) > 1e-6 ||
1557+
Math.abs(expanded.height - this.layoutContainer.height) > 1e-6
1558+
) {
1559+
this.layoutContainer = expanded;
1560+
this.latestCursorLayoutData.containerBox = { ...this.layoutContainer };
1561+
this.cursorLogic.updateData(this.layoutContainer, charBoxes, lineBreakIndices, lineLayouts);
1562+
this.cursorLogic.moveTo(nextIndex, pendingLineHint);
1563+
if (this.selectionStart !== this.selectionEnd) {
1564+
this.cursorLogic.setSelection(this.selectionStart, this.selectionEnd);
1565+
} else {
1566+
this.cursorLogic.clearSelection();
1567+
}
1568+
this.syncStateFromCursor();
1569+
}
1570+
}
1571+
1572+
/**
1573+
* Extends {@link layoutContainer} vertically so empty logical rows (no glyphs)
1574+
* remain inside the chrome bounds. Intentionally ignores non-empty rows: their
1575+
* `LineInfo` may still use inflated renderer line strips, while
1576+
* {@link computeEditorVerticalBounds} already tightened using glyph boxes.
1577+
*/
1578+
private unionLayoutContainerWithCursorLines(container: Box): Box {
1579+
const lines = this.cursorLogic.getLines();
1580+
if (lines.length === 0) return container;
1581+
1582+
let low = container.y;
1583+
let high = container.y + container.height;
1584+
1585+
for (const line of lines) {
1586+
if (line.charCount !== 0) continue;
1587+
const lineLo = line.y - line.height / 2;
1588+
const lineHi = line.y + line.height / 2;
1589+
if (Number.isFinite(lineLo)) low = Math.min(low, lineLo);
1590+
if (Number.isFinite(lineHi)) high = Math.max(high, lineHi);
1591+
}
1592+
1593+
if (!Number.isFinite(low) || !Number.isFinite(high) || high < low) {
1594+
return container;
1595+
}
1596+
1597+
return {
1598+
...container,
1599+
y: low,
1600+
height: high - low
1601+
};
14691602
}
14701603

14711604
/**
@@ -1593,13 +1726,11 @@ export class MTextInputBox {
15931726
);
15941727

15951728
const containerBox = {
1596-
x: local.x,
1729+
x: attachmentColumnMinLocal(this.width, this.editorAttachmentPoint),
15971730
y: containerTop,
1598-
width: local.width,
1731+
width: Math.max(1, this.width),
15991732
height: Math.max(0, containerBottom - containerTop)
16001733
};
1601-
containerBox.x = 0;
1602-
containerBox.width = this.width;
16031734
const minHeight = this.getFallbackLineAdvance();
16041735
if (containerBox.height < minHeight) {
16051736
const delta = minHeight - containerBox.height;
@@ -1691,7 +1822,7 @@ export class MTextInputBox {
16911822

16921823
return {
16931824
containerBox: {
1694-
x: 0,
1825+
x: attachmentColumnMinLocal(this.width, this.editorAttachmentPoint),
16951826
y: minY,
16961827
width: this.width,
16971828
height: Math.max(1, -minY)
@@ -1759,11 +1890,31 @@ export class MTextInputBox {
17591890

17601891
private replaceRenderedObject(object: MTextObject): void {
17611892
this.disposeRenderedObject(this.renderedObject);
1893+
this.removeStrayInputBoxSceneRootsExcept(object);
17621894
this.forceVisibleMaterialState(object);
1895+
(object as unknown as THREE.Object3D).userData[MTEXT_INPUT_BOX_SCENE_ROOT_KEY] = this;
17631896
this.renderedObject = object;
17641897
this.scene.add(object);
17651898
}
17661899

1900+
/**
1901+
* Removes any previous MTEXT scene roots tagged for this input box. Defensive
1902+
* cleanup when a prior root was not fully detached before the next relayout.
1903+
*/
1904+
private removeStrayInputBoxSceneRootsExcept(keep: MTextObject): void {
1905+
const keepObj = keep as unknown as THREE.Object3D;
1906+
for (let i = this.scene.children.length - 1; i >= 0; i--) {
1907+
const ch = this.scene.children[i];
1908+
if (!ch || ch === keepObj) continue;
1909+
const owner = (ch.userData as Record<string, unknown>)[MTEXT_INPUT_BOX_SCENE_ROOT_KEY];
1910+
if (owner === this) {
1911+
this.scene.remove(ch);
1912+
this.disposeMTextRootResources(ch as MTextObject);
1913+
delete (ch.userData as Record<string, unknown>)[MTEXT_INPUT_BOX_SCENE_ROOT_KEY];
1914+
}
1915+
}
1916+
}
1917+
17671918
private forceVisibleMaterialState(object: MTextObject): void {
17681919
object.traverse((child: THREE.Object3D) => {
17691920
const meshLike = child as THREE.Mesh;
@@ -1795,17 +1946,8 @@ export class MTextInputBox {
17951946
});
17961947
}
17971948

1798-
private disposeRenderedObject(object: MTextObject | null): void {
1799-
if (!object) return;
1800-
object.removeFromParent();
1801-
1802-
const withDispose = object as MTextObject & { dispose?: () => void };
1803-
if (typeof withDispose.dispose === 'function') {
1804-
withDispose.dispose();
1805-
return;
1806-
}
1807-
1808-
object.traverse((child: THREE.Object3D) => {
1949+
private disposeDetachedThreeSubtree(root: THREE.Object3D): void {
1950+
root.traverse((child: THREE.Object3D) => {
18091951
const mesh = child as THREE.Mesh;
18101952
if (mesh.geometry) {
18111953
mesh.geometry.dispose();
@@ -1821,6 +1963,36 @@ export class MTextInputBox {
18211963
});
18221964
}
18231965

1966+
/**
1967+
* Keeps a single layout root on the MTEXT object. {@link MText.syncDraw} only calls
1968+
* `add()` for the new layout; multiple draws on the same instance would otherwise
1969+
* stack several full copies of the text (e.g. after attachment-point changes).
1970+
*/
1971+
private pruneExtraMTextLayoutRoots(object: MTextObject): void {
1972+
while (object.children.length > 1) {
1973+
const stale = object.children[0];
1974+
if (!stale) break;
1975+
object.remove(stale);
1976+
this.disposeDetachedThreeSubtree(stale);
1977+
}
1978+
}
1979+
1980+
private disposeMTextRootResources(object: MTextObject): void {
1981+
const withDispose = object as MTextObject & { dispose?: () => void };
1982+
if (typeof withDispose.dispose === 'function') {
1983+
withDispose.dispose();
1984+
return;
1985+
}
1986+
this.disposeDetachedThreeSubtree(object);
1987+
}
1988+
1989+
private disposeRenderedObject(object: MTextObject | null): void {
1990+
if (!object) return;
1991+
object.removeFromParent();
1992+
delete (object as unknown as THREE.Object3D).userData[MTEXT_INPUT_BOX_SCENE_ROOT_KEY];
1993+
this.disposeMTextRootResources(object);
1994+
}
1995+
18241996
private isExplicitAci(aci: number | null): aci is number {
18251997
return aci !== null && Number.isInteger(aci) && aci > 0 && aci < 256;
18261998
}
@@ -2831,6 +3003,47 @@ export class MTextInputBox {
28313003
return new MTextDocument(normalizedAst);
28323004
}
28333005

3006+
/**
3007+
* World-space point on the layout bounds that matches the given MTEXT
3008+
* attachment (DXF 71), using the same box convention as {@link updateBoundingBoxGeometry}.
3009+
*/
3010+
private computeAttachmentAnchorOnBounds(
3011+
left: number,
3012+
right: number,
3013+
bottom: number,
3014+
top: number,
3015+
point: MTextAttachmentPoint
3016+
): THREE.Vector3 {
3017+
const midX = (left + right) * 0.5;
3018+
const midY = (bottom + top) * 0.5;
3019+
const z = this.position.z;
3020+
switch (point) {
3021+
case MTextAttachmentPoint.TopLeft:
3022+
return new THREE.Vector3(left, top, z);
3023+
case MTextAttachmentPoint.TopCenter:
3024+
return new THREE.Vector3(midX, top, z);
3025+
case MTextAttachmentPoint.TopRight:
3026+
return new THREE.Vector3(right, top, z);
3027+
case MTextAttachmentPoint.MiddleLeft:
3028+
return new THREE.Vector3(left, midY, z);
3029+
case MTextAttachmentPoint.MiddleCenter:
3030+
return new THREE.Vector3(midX, midY, z);
3031+
case MTextAttachmentPoint.MiddleRight:
3032+
return new THREE.Vector3(right, midY, z);
3033+
case MTextAttachmentPoint.BottomLeft:
3034+
case MTextAttachmentPoint.BaselineLeft:
3035+
return new THREE.Vector3(left, bottom, z);
3036+
case MTextAttachmentPoint.BottomCenter:
3037+
case MTextAttachmentPoint.BaselineCenter:
3038+
return new THREE.Vector3(midX, bottom, z);
3039+
case MTextAttachmentPoint.BottomRight:
3040+
case MTextAttachmentPoint.BaselineRight:
3041+
return new THREE.Vector3(right, bottom, z);
3042+
default:
3043+
return new THREE.Vector3(left, top, z);
3044+
}
3045+
}
3046+
28343047
private mapRibbonParagraphAlignment(alignment: string): MTextParagraphAlignment | undefined {
28353048
switch (alignment) {
28363049
case 'default':

0 commit comments

Comments
 (0)