Skip to content

Commit 95c8fc9

Browse files
author
Alexander
committed
ShapeManager. Правим баг когда при увеличении размера текста он вылезает за пределы области выделения.
1 parent 5ea8446 commit 95c8fc9

File tree

2 files changed

+209
-2
lines changed

2 files changed

+209
-2
lines changed

src/editor/shape-manager/shape-layout.ts

Lines changed: 197 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
/* eslint-disable no-use-before-define, @typescript-eslint/no-use-before-define */
2+
13
import { Point } from 'fabric'
24
import { resizeShapeNode } from './shape-factory'
35
import {
@@ -11,6 +13,7 @@ const TEXT_FRAME_FILL_EPSILON = 0.5
1113
const MAX_HORIZONTAL_PADDING_PX = 12
1214
const MAX_VERTICAL_PADDING_PX = 12
1315
const MAX_PADDING_RATIO = 0.45
16+
const MAX_WIDTH_RESIZE_ITERATIONS = 8
1417
const MAX_HEIGHT_RESIZE_ITERATIONS = 8
1518

1619
type TextboxMeasurementState = {
@@ -45,10 +48,15 @@ export const applyShapeTextLayout = ({
4548
alignV,
4649
padding
4750
}: ShapeLayoutInput): void => {
48-
const safeWidth = Math.max(MIN_TEXT_FRAME_SIZE, width)
4951
const normalizedPadding = normalizePadding({
5052
padding
5153
})
54+
const minWidth = resolveRequiredShapeWidthForText({
55+
text,
56+
width,
57+
padding: normalizedPadding
58+
})
59+
const safeWidth = Math.max(MIN_TEXT_FRAME_SIZE, minWidth)
5260
const minHeight = resolveRequiredShapeHeightForText({
5361
text,
5462
width: safeWidth,
@@ -246,6 +254,54 @@ export const resolveRequiredShapeHeightForText = ({
246254
return nextHeight
247255
}
248256

257+
/**
258+
* Возвращает минимальную ширину shape, при которой текст не выходит за пределы текстового фрейма.
259+
*/
260+
function resolveRequiredShapeWidthForText({
261+
text,
262+
width,
263+
padding
264+
}: {
265+
text: ShapeLayoutInput['text']
266+
width: number
267+
padding: ShapePadding
268+
}): number {
269+
const safeWidth = Math.max(MIN_TEXT_FRAME_SIZE, width)
270+
const normalizedPadding = normalizePadding({
271+
padding
272+
})
273+
const currentFrameWidth = resolveTextFrameWidth({
274+
width: safeWidth,
275+
padding: normalizedPadding
276+
})
277+
const requiredFrameWidth = resolveRequiredTextFrameWidth({
278+
text,
279+
frameWidth: currentFrameWidth
280+
})
281+
282+
if (requiredFrameWidth <= currentFrameWidth + TEXT_FRAME_FILL_EPSILON) {
283+
return safeWidth
284+
}
285+
286+
let nextWidth = safeWidth
287+
288+
for (let iteration = 0; iteration < MAX_WIDTH_RESIZE_ITERATIONS; iteration += 1) {
289+
const nextFrameWidth = resolveTextFrameWidth({
290+
width: nextWidth,
291+
padding: normalizedPadding
292+
})
293+
294+
if (nextFrameWidth >= requiredFrameWidth - TEXT_FRAME_FILL_EPSILON) {
295+
return nextWidth
296+
}
297+
298+
const missingWidth = requiredFrameWidth - nextFrameWidth
299+
nextWidth = Math.max(nextWidth + missingWidth, nextWidth * 1.05)
300+
}
301+
302+
return nextWidth
303+
}
304+
249305
/**
250306
* Возвращает центр для размещения группы на канвасе.
251307
*/
@@ -328,6 +384,30 @@ function createTextFrame({
328384
}
329385
}
330386

387+
/**
388+
* Возвращает доступную ширину текстового фрейма для переданной ширины шейпа.
389+
*/
390+
function resolveTextFrameWidth({
391+
width,
392+
padding
393+
}: {
394+
width: number
395+
padding: ShapePadding
396+
}): number {
397+
const leftPadding = resolvePaddingPixels({
398+
size: width,
399+
ratio: padding.left,
400+
axis: 'horizontal'
401+
})
402+
const rightPadding = resolvePaddingPixels({
403+
size: width,
404+
ratio: padding.right,
405+
axis: 'horizontal'
406+
})
407+
408+
return Math.max(MIN_TEXT_FRAME_SIZE, width - leftPadding - rightPadding)
409+
}
410+
331411
/**
332412
* Возвращает визуальную высоту textbox.
333413
*/
@@ -384,6 +464,71 @@ function measureTextboxHeightForFrame({
384464
return measuredHeight
385465
}
386466

467+
/**
468+
* Возвращает требуемую ширину текстового фрейма, если даже перенос по символам не устраняет горизонтальный overflow.
469+
*/
470+
function resolveRequiredTextFrameWidth({
471+
text,
472+
frameWidth
473+
}: {
474+
text: ShapeLayoutInput['text']
475+
frameWidth: number
476+
}): number {
477+
const safeFrameWidth = Math.max(MIN_TEXT_FRAME_SIZE, frameWidth)
478+
const longestWordWidth = measureTextboxLongestLineWidthForFrame({
479+
text,
480+
frameWidth: safeFrameWidth,
481+
splitByGrapheme: false
482+
})
483+
484+
if (longestWordWidth <= safeFrameWidth + TEXT_FRAME_FILL_EPSILON) {
485+
return safeFrameWidth
486+
}
487+
488+
const longestGraphemeLineWidth = measureTextboxLongestLineWidthForFrame({
489+
text,
490+
frameWidth: safeFrameWidth,
491+
splitByGrapheme: true
492+
})
493+
494+
if (longestGraphemeLineWidth <= safeFrameWidth + TEXT_FRAME_FILL_EPSILON) {
495+
return safeFrameWidth
496+
}
497+
498+
return longestGraphemeLineWidth
499+
}
500+
501+
/**
502+
* Измеряет максимальную ширину строки textbox при заданной ширине фрейма и режиме переноса.
503+
*/
504+
function measureTextboxLongestLineWidthForFrame({
505+
text,
506+
frameWidth,
507+
splitByGrapheme
508+
}: {
509+
text: ShapeLayoutInput['text']
510+
frameWidth: number
511+
splitByGrapheme: boolean
512+
}): number {
513+
const previousState = captureTextboxMeasurementState({ text })
514+
515+
text.set({
516+
autoExpand: false,
517+
width: Math.max(MIN_TEXT_FRAME_SIZE, frameWidth),
518+
splitByGrapheme
519+
})
520+
521+
text.initDimensions()
522+
const longestLineWidth = getTextboxLongestLineWidth({ text })
523+
524+
restoreTextboxMeasurementState({
525+
text,
526+
state: previousState
527+
})
528+
529+
return longestLineWidth
530+
}
531+
387532
/**
388533
* Вычисляет верхнюю координату текста по вертикальному выравниванию.
389534
*/
@@ -457,6 +602,57 @@ function resolveSplitByGraphemeForFrame({
457602
return shouldSplitByGrapheme
458603
}
459604

605+
/**
606+
* Возвращает ширину самой длинной отрисованной строки textbox.
607+
*/
608+
function getTextboxLongestLineWidth({
609+
text
610+
}: {
611+
text: ShapeLayoutInput['text']
612+
}): number {
613+
const textbox = text as ShapeLayoutInput['text'] & {
614+
textLines?: string[]
615+
}
616+
617+
if (Array.isArray(textbox.textLines) && textbox.textLines.length > 0) {
618+
return measureLongestRenderedLineWidth({
619+
text,
620+
lineCount: textbox.textLines.length
621+
})
622+
}
623+
624+
const rawText = textbox.text ?? ''
625+
const lineCount = Math.max(rawText.split('\n').length, 1)
626+
627+
return measureLongestRenderedLineWidth({
628+
text,
629+
lineCount
630+
})
631+
}
632+
633+
/**
634+
* Измеряет ширину самой длинной уже отрисованной строки textbox.
635+
*/
636+
function measureLongestRenderedLineWidth({
637+
text,
638+
lineCount
639+
}: {
640+
text: ShapeLayoutInput['text']
641+
lineCount: number
642+
}): number {
643+
let longestLineWidth = MIN_TEXT_FRAME_SIZE
644+
645+
for (let lineIndex = 0; lineIndex < lineCount; lineIndex += 1) {
646+
const lineWidth = text.getLineWidth(lineIndex)
647+
648+
if (lineWidth > longestLineWidth) {
649+
longestLineWidth = lineWidth
650+
}
651+
}
652+
653+
return longestLineWidth
654+
}
655+
460656
/**
461657
* Возвращает текущее состояние textbox для временных измерений.
462658
*/

src/editor/shape-manager/shape-scaling.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,12 +122,23 @@ export default class ShapeScalingController {
122122
height: nextHeight,
123123
padding
124124
})
125+
// Определяем, приведёт ли следующий размер к необходимости разбиения по графемам
126+
// (т.е. когда одна буква/слово уже не помещается в доступную ширину фрейма).
127+
const { splitByGrapheme: wouldRequireGrapheme } = resolveShapeTextFrameLayout({
128+
text,
129+
width: nextWidth,
130+
height: nextHeight,
131+
alignV: group.shapeAlignVertical ?? SHAPE_DEFAULT_VERTICAL_ALIGN,
132+
padding
133+
})
125134
const isScalingDownX = scaleX < state.lastAllowedScaleX - SCALE_EPSILON
126135
const isScalingDownY = scaleY < state.lastAllowedScaleY - SCALE_EPSILON
127136
const isBelowStartScaleX = scaleX < state.startScaleX - SCALE_EPSILON
128137
const isBelowStartScaleY = scaleY < state.startScaleY - SCALE_EPSILON
129138
const shouldBlockByStart = state.cannotScaleDownAtStart && (isBelowStartScaleX || isBelowStartScaleY)
130-
const shouldBlockByText = wouldFillTextFrame && (isScalingDownX || isScalingDownY)
139+
// Блокируем уменьшение, если текст заполнит фрейм по высоте или если
140+
// потребуется принудительный перенос по графемам (символы слишком широки).
141+
const shouldBlockByText = (wouldFillTextFrame || wouldRequireGrapheme) && (isScalingDownX || isScalingDownY)
131142
const shouldBlockByCornerCross = state.crossedOppositeCorner
132143
const shouldBlockScaling = shouldBlockByStart || shouldBlockByText || shouldBlockByCornerCross
133144
const isStateAtStart = ShapeScalingController._isStateAtStart({

0 commit comments

Comments
 (0)