Skip to content

Commit 8174f7e

Browse files
authored
fix: fix MTEXT top-left alignment and cursor anchoring (#7)
1 parent 6f460ae commit 8174f7e

3 files changed

Lines changed: 104 additions & 26 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.3",
3+
"version": "0.2.4",
44
"type": "module",
55
"main": "./dist/index.js",
66
"types": "./dist/index.d.ts",

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

Lines changed: 34 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ export class MTextInputBox {
7777

7878
private width: number;
7979
private position: THREE.Vector3;
80+
private mtextInsertionOffset = new THREE.Vector3(0, 0, 0);
8081
private enableWordWrap: boolean;
8182

8283
private mtextString = '';
@@ -322,6 +323,9 @@ export class MTextInputBox {
322323
});
323324

324325
const cursorStyle: Partial<CursorStyle> = { ...(options.cursorStyle ?? {}) };
326+
if (cursorStyle.height === undefined && cursorStyle.heightMode !== 'fixed') {
327+
cursorStyle.height = 0;
328+
}
325329
cursorStyle.color ??= '#ffffff';
326330
cursorStyle.glowColor ??= '#ffffff';
327331

@@ -886,14 +890,20 @@ export class MTextInputBox {
886890

887891
/** Returns cursor world position for IME/caret anchoring. */
888892
public getCursorWorldPosition(): { x: number; y: number; z: number } {
889-
const cursor = this.cursorLogic.getCursorState().position;
893+
const cursorState = this.cursorLogic.getCursorState();
894+
const cursor = this.getActiveCursorRenderState(cursorState.position, cursorState.lineInfo.height).position;
890895
return {
891896
x: this.position.x + cursor.x,
892897
y: this.position.y + cursor.y,
893898
z: this.position.z
894899
};
895900
}
896901

902+
/** Returns the MTEXT insertion point that preserves the editor's visual top-left placement. */
903+
public getMTextInsertionPoint(): THREE.Vector3 {
904+
return this.position.clone().add(this.mtextInsertionOffset);
905+
}
906+
897907
/** Returns current internal state snapshot. */
898908
public getState(): EditorState {
899909
return {
@@ -1179,6 +1189,7 @@ export class MTextInputBox {
11791189

11801190
private relayout(): void {
11811191
if (!this.rendererReady) {
1192+
this.mtextInsertionOffset.set(0, 0, 0);
11821193
const fallback = this.createFallbackCharBoxes();
11831194
this.layoutContainer = fallback.containerBox;
11841195
this.updateCursorData(fallback.charBoxes, fallback.lineBreakIndices, fallback.lineLayouts);
@@ -1201,12 +1212,14 @@ export class MTextInputBox {
12011212
this.replaceRenderedObject(object);
12021213

12031214
const rendered = this.extractBoxesFromRenderedObject(object);
1215+
this.mtextInsertionOffset.set(0, 0, 0);
12041216
this.normalizeRenderedTopAlignment(object, rendered);
12051217
this.layoutContainer = rendered.containerBox;
12061218
this.updateCursorData(rendered.charBoxes, rendered.lineBreakIndices, rendered.lineLayouts);
12071219
this.updateBoundingBoxGeometry();
12081220
} catch (error) {
12091221
console.error('[mtext-input-box] Failed to sync render MTEXT', error);
1222+
this.mtextInsertionOffset.set(0, 0, 0);
12101223
const fallback = this.createFallbackCharBoxes();
12111224
this.layoutContainer = fallback.containerBox;
12121225
this.updateCursorData(fallback.charBoxes, fallback.lineBreakIndices, fallback.lineLayouts);
@@ -1349,9 +1362,11 @@ export class MTextInputBox {
13491362
object: MTextObject,
13501363
rendered: CursorLayoutData
13511364
): void {
1352-
const dy = -rendered.containerBox.y;
1365+
const topY = rendered.containerBox.y + rendered.containerBox.height;
1366+
const dy = -topY;
13531367
if (!Number.isFinite(dy) || Math.abs(dy) < 1e-8) return;
13541368

1369+
this.mtextInsertionOffset.set(0, dy, 0);
13551370
object.position.y += dy;
13561371
object.updateMatrixWorld(true);
13571372

@@ -1393,38 +1408,39 @@ export class MTextInputBox {
13931408
const lineLayouts: LineLayoutInput[] = [];
13941409

13951410
let x = 0;
1396-
let y = 0;
1397-
let maxY = lineHeight;
1398-
lineLayouts.push({ y: y + lineHeight / 2, height: lineHeight });
1411+
let lineTop = 0;
1412+
let minY = -lineHeight;
1413+
lineLayouts.push({ y: lineTop - lineHeight / 2, height: lineHeight });
13991414

14001415
for (const char of this.getChars()) {
14011416
if (char === '\n') {
14021417
lineBreakIndices.push(charBoxes.length);
14031418
x = 0;
1404-
y += lineHeight;
1405-
maxY = Math.max(maxY, y + lineHeight);
1406-
lineLayouts.push({ y: y + lineHeight / 2, height: lineHeight });
1419+
lineTop -= lineHeight;
1420+
minY = Math.min(minY, lineTop - lineHeight);
1421+
lineLayouts.push({ y: lineTop - lineHeight / 2, height: lineHeight });
14071422
continue;
14081423
}
14091424

14101425
const width = Math.max(1, this.currentFormat.fontSize * 0.6);
14111426
if (this.enableWordWrap && x > 0 && x + width > this.width) {
14121427
lineBreakIndices.push(charBoxes.length);
14131428
x = 0;
1414-
y += lineHeight;
1429+
lineTop -= lineHeight;
1430+
minY = Math.min(minY, lineTop - lineHeight);
1431+
lineLayouts.push({ y: lineTop - lineHeight / 2, height: lineHeight });
14151432
}
14161433

1417-
charBoxes.push({ x, y: y + lineHeight / 2, width, height: lineHeight });
1434+
charBoxes.push({ x, y: lineTop - lineHeight / 2, width, height: lineHeight });
14181435
x += width;
1419-
maxY = Math.max(maxY, y + lineHeight);
14201436
}
14211437

14221438
return {
14231439
containerBox: {
14241440
x: 0,
1425-
y: 0,
1441+
y: minY,
14261442
width: this.width,
1427-
height: Math.max(1, maxY)
1443+
height: Math.max(1, -minY)
14281444
},
14291445
charBoxes,
14301446
lineBreakIndices,
@@ -1763,7 +1779,7 @@ export class MTextInputBox {
17631779
// Best source: renderer-provided line layout already represents
17641780
// the actual first-line center in editor local coordinates.
17651781
position: { x: fallbackPosition.x, y: firstLine.y },
1766-
height: Math.max(1, lineHeight * 0.8)
1782+
height: lineHeight
17671783
};
17681784
}
17691785

@@ -1772,12 +1788,12 @@ export class MTextInputBox {
17721788
position: {
17731789
x: fallbackPosition.x,
17741790
// When explicit line layout is unavailable, infer first-line center
1775-
// from top-left container coordinates:
1776-
// lineCenterY = containerTopY + lineHeight / 2
1791+
// from the container's lower edge in y-up coordinates:
1792+
// lineCenterY = containerBottomY + lineHeight / 2
17771793
// This keeps caret aligned with the top row for empty MTEXT.
17781794
y: this.layoutContainer.y + inferredLineHeight / 2
17791795
},
1780-
height: Math.max(1, inferredLineHeight * 0.8)
1796+
height: inferredLineHeight
17811797
};
17821798
}
17831799

@@ -1796,7 +1812,7 @@ export class MTextInputBox {
17961812
}
17971813
}
17981814

1799-
const emptyLineHeight = (line.height ?? fallbackHeight) * 0.8;
1815+
const emptyLineHeight = line.height ?? fallbackHeight;
18001816
return {
18011817
position: { x: fallbackPosition.x, y: line.y ?? fallbackPosition.y },
18021818
height: Math.max(1, emptyLineHeight)

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

Lines changed: 69 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,69 @@ describe('MTextInputBox cursor/document index mapping', () => {
306306
});
307307
});
308308

309-
test('anchors empty-content cursor to first line center from container top', () => {
309+
test('normalizes rendered content so position stays at the top-left edge', () => {
310+
const proto = MTextInputBox.prototype as unknown as Record<string, (...args: any[]) => any>;
311+
const normalizeRenderedTopAlignment = proto.normalizeRenderedTopAlignment as (this: any, obj: any, data: any) => void;
312+
const object = {
313+
position: new THREE.Vector3(0, 0, 0),
314+
updateMatrixWorld: vi.fn()
315+
};
316+
const context = {
317+
mtextInsertionOffset: new THREE.Vector3(0, 0, 0)
318+
};
319+
const rendered = {
320+
containerBox: { x: 0, y: -14, width: 120, height: 24 },
321+
charBoxes: [{ x: 0, y: 5, width: 10, height: 10 }],
322+
lineLayouts: [{ y: 5, height: 10 }]
323+
};
324+
325+
normalizeRenderedTopAlignment.call(context, object, rendered);
326+
327+
expect(object.position.y).toBe(-10);
328+
expect(object.updateMatrixWorld).toHaveBeenCalledWith(true);
329+
expect(context.mtextInsertionOffset.toArray()).toEqual([0, -10, 0]);
330+
expect(rendered.containerBox).toEqual({ x: 0, y: -24, width: 120, height: 24 });
331+
expect(rendered.charBoxes[0].y).toBe(-5);
332+
expect(rendered.lineLayouts[0].y).toBe(-5);
333+
});
334+
335+
test('returns persisted MTEXT insertion point with render alignment offset', () => {
336+
const proto = MTextInputBox.prototype as unknown as Record<string, (...args: any[]) => any>;
337+
const getMTextInsertionPoint = proto.getMTextInsertionPoint as (this: any) => THREE.Vector3;
338+
const context = {
339+
position: new THREE.Vector3(10, 20, 2),
340+
mtextInsertionOffset: new THREE.Vector3(0, -10, 0)
341+
};
342+
343+
const result = getMTextInsertionPoint.call(context);
344+
345+
expect(result.toArray()).toEqual([10, 10, 2]);
346+
expect(result).not.toBe(context.position);
347+
});
348+
349+
test('fallback layout extends below the top-left insertion point', () => {
350+
const proto = MTextInputBox.prototype as unknown as Record<string, (...args: any[]) => any>;
351+
const createFallbackCharBoxes = proto.createFallbackCharBoxes as (this: any) => {
352+
containerBox: { x: number; y: number; width: number; height: number };
353+
charBoxes: Array<{ x: number; y: number; width: number; height: number }>;
354+
lineLayouts: Array<{ y: number; height: number }>;
355+
};
356+
const context = {
357+
width: 120,
358+
currentFormat: { fontSize: 10 },
359+
enableWordWrap: true,
360+
getChars: () => ['A'],
361+
getFallbackLineAdvance: () => 15
362+
};
363+
364+
const result = createFallbackCharBoxes.call(context);
365+
366+
expect(result.containerBox).toEqual({ x: 0, y: -15, width: 120, height: 15 });
367+
expect(result.lineLayouts).toEqual([{ y: -7.5, height: 15 }]);
368+
expect(result.charBoxes[0]).toMatchObject({ y: -7.5, height: 15 });
369+
});
370+
371+
test('anchors empty-content cursor to first line center below container top', () => {
310372
const proto = MTextInputBox.prototype as unknown as Record<string, (...args: any[]) => any>;
311373
const getActiveCursorRenderState = proto.getActiveCursorRenderState as (
312374
this: any,
@@ -318,17 +380,17 @@ describe('MTextInputBox cursor/document index mapping', () => {
318380
cursorLogic: {
319381
getCharBoxes: () => [],
320382
getCurrentIndex: () => 0,
321-
getCurrentLineInfo: () => ({ startIndex: 0, endIndex: -1, charCount: 0, y: 220, height: 220 })
383+
getCurrentLineInfo: () => ({ startIndex: 0, endIndex: -1, charCount: 0, y: -24, height: 24 })
322384
},
323-
latestCursorLayoutData: { containerBox: { x: 0, y: 200, width: 300, height: 220 }, charBoxes: [] },
324-
layoutContainer: { x: 0, y: 200, width: 300, height: 220 },
385+
latestCursorLayoutData: { containerBox: { x: 0, y: -24, width: 300, height: 24 }, charBoxes: [] },
386+
layoutContainer: { x: 0, y: -24, width: 300, height: 24 },
325387
getFallbackLineAdvance: () => 24
326388
};
327389

328-
const result = getActiveCursorRenderState.call(context, { x: 0, y: 220 }, 220);
390+
const result = getActiveCursorRenderState.call(context, { x: 0, y: -24 }, 24);
329391

330-
expect(result.position).toEqual({ x: 0, y: 212 });
331-
expect(result.height).toBeCloseTo(19.2);
392+
expect(result.position).toEqual({ x: 0, y: -12 });
393+
expect(result.height).toBeCloseTo(24);
332394
});
333395

334396
test('handleKeyDown closes editor on Escape', () => {

0 commit comments

Comments
 (0)