Skip to content

Commit 1986e05

Browse files
committed
fix: tighten editor vertical bounds from glyphs vs line strips
1 parent 8257cb6 commit 1986e05

5 files changed

Lines changed: 229 additions & 68 deletions

File tree

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@
1111
"esbuild",
1212
"nx",
1313
"vue-demi"
14-
]
14+
],
15+
"overrides": {
16+
"@mlightcad/mtext-renderer": "../../../mtext-renderer/packages/mtext-renderer"
17+
}
1518
},
1619
"scripts": {
1720
"build": "pnpm --filter @mlightcad/text-box-cursor build && pnpm --filter @mlightcad/mtext-input-box build && pnpm --filter @mlightcad/demo-canvas-cursor build && pnpm --filter @mlightcad/demo-three-cursor build && pnpm --filter @mlightcad/demo-mtext-input-box build",

packages/mtext-input-box/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"scripts": {
1717
"build": "tsc -p tsconfig.build.json",
1818
"lint": "eslint src tests",
19-
"test": "vitest run --config ./vitest.config.ts"
19+
"test": "vitest run"
2020
},
2121
"dependencies": {
2222
"@mlightcad/text-box-cursor": "workspace:*"
@@ -33,6 +33,7 @@
3333
"@typescript-eslint/parser": "^8.41.0",
3434
"eslint": "^9.34.0",
3535
"eslint-config-prettier": "^10.1.8",
36+
"rollup-plugin-peer-deps-external": "^2.2.4",
3637
"typescript": "^5.9.2",
3738
"typescript-eslint": "^8.41.0",
3839
"vitest": "^3.2.4"

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

Lines changed: 102 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1468,6 +1468,97 @@ export class MTextInputBox {
14681468
this.syncStateFromCursor();
14691469
}
14701470

1471+
/**
1472+
* Editor vertical bounds for the selection box / bounding overlay.
1473+
* Renderer `layout.lines` strips are often taller than actual glyphs (leading
1474+
* above the first row, uniform line-height slots, etc.). When a line has
1475+
* glyph boxes, use those for that row; keep the strip only for empty lines.
1476+
*/
1477+
private computeEditorVerticalBounds(
1478+
lineLayouts: LineLayoutInput[],
1479+
charBoxes: Box[],
1480+
objectLocal: Box
1481+
): { minY: number; maxY: number } {
1482+
const fallback = (): { minY: number; maxY: number } => ({
1483+
minY: objectLocal.y - objectLocal.height / 2,
1484+
maxY: objectLocal.y + objectLocal.height / 2
1485+
});
1486+
1487+
const overlapY = (a0: number, a1: number, b0: number, b1: number): boolean =>
1488+
a0 < b1 + 1e-4 && a1 > b0 - 1e-4;
1489+
1490+
if (lineLayouts.length === 0 && charBoxes.length === 0) {
1491+
return fallback();
1492+
}
1493+
1494+
if (lineLayouts.length === 0) {
1495+
let minY = Number.POSITIVE_INFINITY;
1496+
let maxY = Number.NEGATIVE_INFINITY;
1497+
for (const b of charBoxes) {
1498+
minY = Math.min(minY, b.y - b.height / 2);
1499+
maxY = Math.max(maxY, b.y + b.height / 2);
1500+
}
1501+
return Number.isFinite(minY) && Number.isFinite(maxY) && maxY >= minY
1502+
? { minY, maxY }
1503+
: fallback();
1504+
}
1505+
1506+
if (charBoxes.length === 0) {
1507+
let minY = Number.POSITIVE_INFINITY;
1508+
let maxY = Number.NEGATIVE_INFINITY;
1509+
for (const line of lineLayouts) {
1510+
const lo = line.y - line.height / 2;
1511+
const hi = line.y + line.height / 2;
1512+
minY = Math.min(minY, lo);
1513+
maxY = Math.max(maxY, hi);
1514+
}
1515+
return Number.isFinite(minY) && Number.isFinite(maxY) && maxY >= minY
1516+
? { minY, maxY }
1517+
: fallback();
1518+
}
1519+
1520+
let minY = Number.POSITIVE_INFINITY;
1521+
let maxY = Number.NEGATIVE_INFINITY;
1522+
1523+
for (const line of lineLayouts) {
1524+
const lo = line.y - line.height / 2;
1525+
const hi = line.y + line.height / 2;
1526+
const onLine = charBoxes.filter((b) => {
1527+
const c0 = b.y - b.height / 2;
1528+
const c1 = b.y + b.height / 2;
1529+
return overlapY(c0, c1, lo, hi);
1530+
});
1531+
if (onLine.length === 0) {
1532+
minY = Math.min(minY, lo);
1533+
maxY = Math.max(maxY, hi);
1534+
} else {
1535+
for (const b of onLine) {
1536+
minY = Math.min(minY, b.y - b.height / 2);
1537+
maxY = Math.max(maxY, b.y + b.height / 2);
1538+
}
1539+
}
1540+
}
1541+
1542+
for (const b of charBoxes) {
1543+
const c0 = b.y - b.height / 2;
1544+
const c1 = b.y + b.height / 2;
1545+
const overlapsAnyLine = lineLayouts.some((line) => {
1546+
const lo = line.y - line.height / 2;
1547+
const hi = line.y + line.height / 2;
1548+
return overlapY(c0, c1, lo, hi);
1549+
});
1550+
if (!overlapsAnyLine) {
1551+
minY = Math.min(minY, c0);
1552+
maxY = Math.max(maxY, c1);
1553+
}
1554+
}
1555+
1556+
if (!Number.isFinite(minY) || !Number.isFinite(maxY) || maxY < minY) {
1557+
return fallback();
1558+
}
1559+
return { minY, maxY };
1560+
}
1561+
14711562
private extractBoxesFromRenderedObject(object: MTextObject): CursorLayoutData {
14721563
const layout = object.createLayoutData();
14731564
const charBoxes: Box[] = [];
@@ -1495,17 +1586,11 @@ export class MTextInputBox {
14951586
.filter((value) => value >= 0 && value <= charBoxes.length);
14961587

14971588
const local = this.toLocalBox(object.box);
1498-
let containerTop = local.y - local.height / 2;
1499-
let containerBottom = local.y + local.height / 2;
1500-
1501-
if (lineLayouts.length > 0) {
1502-
for (const line of lineLayouts) {
1503-
const top = line.y - line.height / 2;
1504-
const bottom = line.y + line.height / 2;
1505-
containerTop = Math.min(containerTop, top);
1506-
containerBottom = Math.max(containerBottom, bottom);
1507-
}
1508-
}
1589+
const { minY: containerTop, maxY: containerBottom } = this.computeEditorVerticalBounds(
1590+
lineLayouts,
1591+
charBoxes,
1592+
local
1593+
);
15091594

15101595
const containerBox = {
15111596
x: local.x,
@@ -1515,7 +1600,12 @@ export class MTextInputBox {
15151600
};
15161601
containerBox.x = 0;
15171602
containerBox.width = this.width;
1518-
containerBox.height = Math.max(containerBox.height, this.getFallbackLineAdvance());
1603+
const minHeight = this.getFallbackLineAdvance();
1604+
if (containerBox.height < minHeight) {
1605+
const delta = minHeight - containerBox.height;
1606+
containerBox.y -= delta;
1607+
containerBox.height = minHeight;
1608+
}
15191609

15201610
return {
15211611
containerBox,

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

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,8 @@ describe('MTextInputBox cursor/document index mapping', () => {
253253
position: { x: 10, y: 20 },
254254
width: 120,
255255
getFallbackLineAdvance: () => 16,
256-
toLocalBox: proto.toLocalBox
256+
toLocalBox: proto.toLocalBox,
257+
computeEditorVerticalBounds: proto.computeEditorVerticalBounds
257258
};
258259

259260
const object = {
@@ -306,6 +307,106 @@ describe('MTextInputBox cursor/document index mapping', () => {
306307
});
307308
});
308309

310+
test('vertical container ignores inflated object.box when layout lines/chars exist', () => {
311+
const proto = MTextInputBox.prototype as unknown as Record<string, (...args: any[]) => any>;
312+
const context = {
313+
position: { x: 10, y: 20 },
314+
width: 120,
315+
getFallbackLineAdvance: () => 16,
316+
toLocalBox: proto.toLocalBox,
317+
computeEditorVerticalBounds: proto.computeEditorVerticalBounds
318+
};
319+
320+
const object = {
321+
// Taller than glyph + line union (extra space above first line).
322+
box: new THREE.Box3(new THREE.Vector3(10, 10, 0), new THREE.Vector3(70, 40, 0)),
323+
createLayoutData: () => ({
324+
chars: [
325+
{
326+
type: 'CHAR',
327+
box: new THREE.Box3(new THREE.Vector3(10, 20, 0), new THREE.Vector3(20, 30, 0)),
328+
char: 'A',
329+
children: []
330+
},
331+
{
332+
type: 'CHAR',
333+
box: new THREE.Box3(new THREE.Vector3(10, 6, 0), new THREE.Vector3(18, 16, 0)),
334+
char: 'B',
335+
children: []
336+
}
337+
],
338+
lines: [
339+
{ y: 25, height: 10, breakIndex: 1 },
340+
{ y: 11, height: 10, breakIndex: undefined }
341+
]
342+
})
343+
};
344+
345+
const extractBoxesFromRenderedObject = proto.extractBoxesFromRenderedObject as (
346+
this: any,
347+
obj: any
348+
) => { containerBox: { x: number; y: number; width: number; height: number } };
349+
350+
const result = extractBoxesFromRenderedObject.call(context, object);
351+
352+
expect(result.containerBox).toEqual({
353+
x: 0,
354+
y: -14,
355+
width: 120,
356+
height: 24
357+
});
358+
});
359+
360+
test('row bounds use glyph boxes when line strip is taller than glyphs on that row', () => {
361+
const proto = MTextInputBox.prototype as unknown as Record<string, (...args: any[]) => any>;
362+
const context = {
363+
position: { x: 10, y: 20 },
364+
width: 120,
365+
getFallbackLineAdvance: () => 16,
366+
toLocalBox: proto.toLocalBox,
367+
computeEditorVerticalBounds: proto.computeEditorVerticalBounds
368+
};
369+
370+
const object = {
371+
box: new THREE.Box3(new THREE.Vector3(10, 14, 0), new THREE.Vector3(70, 30, 0)),
372+
createLayoutData: () => ({
373+
chars: [
374+
{
375+
type: 'CHAR',
376+
box: new THREE.Box3(new THREE.Vector3(10, 20, 0), new THREE.Vector3(20, 30, 0)),
377+
char: 'A',
378+
children: []
379+
},
380+
{
381+
type: 'CHAR',
382+
box: new THREE.Box3(new THREE.Vector3(10, 6, 0), new THREE.Vector3(18, 16, 0)),
383+
char: 'B',
384+
children: []
385+
}
386+
],
387+
lines: [
388+
// Same glyphs as the default test, but an oversized first-line strip (leading slot).
389+
{ y: 25, height: 40, breakIndex: 1 },
390+
{ y: 11, height: 10, breakIndex: undefined }
391+
]
392+
})
393+
};
394+
395+
const extractBoxesFromRenderedObject = proto.extractBoxesFromRenderedObject as (
396+
this: any,
397+
obj: any
398+
) => { containerBox: { x: number; y: number; width: number; height: number } };
399+
400+
const result = extractBoxesFromRenderedObject.call(context, object);
401+
402+
expect(result.containerBox).toEqual({
403+
x: 0,
404+
y: -14,
405+
width: 120,
406+
height: 24
407+
});
408+
});
409+
309410
test('normalizes rendered content so position stays at the top-left edge', () => {
310411
const proto = MTextInputBox.prototype as unknown as Record<string, (...args: any[]) => any>;
311412
const normalizeRenderedTopAlignment = proto.normalizeRenderedTopAlignment as (

0 commit comments

Comments
 (0)