Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/mtext-input-box/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
5 changes: 5 additions & 0 deletions packages/mtext-input-box/src/viewer/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Box, CursorStyle, SelectionStyle } from '@mlightcad/text-box-cursor';
import type {
ColorSettings,
MTextAttachmentPoint,
MTextColor,
MTextParagraphAlignment,
TextStyle
Expand Down Expand Up @@ -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`.
*
Expand Down
167 changes: 162 additions & 5 deletions packages/mtext-input-box/src/viewer/viewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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');
Expand All @@ -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();
Expand Down Expand Up @@ -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
};
}

/**
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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':
Expand Down
92 changes: 84 additions & 8 deletions packages/mtext-input-box/tests/viewer.mapping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, (...args: any[]) => 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<string, (...args: any[]) => any>;
const context = {
Expand Down Expand Up @@ -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<string, (...args: any[]) => 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<string, (...args: any[]) => 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<string, (...args: any[]) => any>;
const handleKeyDown = proto.handleKeyDown as (this: any, event: KeyboardEvent) => boolean;
Expand Down
Loading