11import { useEffect } from "react" ;
2+ import type Konva from "konva" ;
23import { useFontCacheVersion } from "../../hooks/useFontCacheVersion" ;
34import { Ellipse , Group , Rect , Shape , Text } from "react-konva" ;
45import { lookupBoundVariable , shouldShowFallbackTint } from "../../lib/variableBinding" ;
@@ -18,7 +19,8 @@ import { selectionHandlers, type KonvaObjectProps } from "./konvaObjectProps";
1819import { setMeasuredBounds , clearMeasuredBounds } from "./measuredBoundsCache" ;
1920import { DEFAULT_GS_SYMBOL_META , GS_SYMBOLS } from "../../registry/symbol" ;
2021import { GS_SYMBOL_PATHS , GS_VECTOR_CODES , type GsVectorCode } from "../../registry/gsSymbolPaths" ;
21- import { blockBoundsDots , blockJustifyWordPositions , blockLineStartDots , blockLineStepDots , wrapBlockLines , zebraAlignOffsetDots , zebraHangingIndentOffsetDots , zebraJustifyGapDots , zebraLineWidthDots , type ZplRotation } from "../../lib/zebraTextLayout" ;
22+ import { blockBoundsDots , blockJustifyWordPositions , blockLineStartDots , blockLineStepDots , tbBoundsDots , tbLineStepDots , wrapBlockLines , zebraAlignOffsetDots , zebraHangingIndentOffsetDots , zebraJustifyGapDots , zebraLineWidthDots , type ZplRotation } from "../../lib/zebraTextLayout" ;
23+ import { resolveTextMode } from "../../registry/text" ;
2224import type { LeafObject } from "../../registry" ;
2325import type { TextProps } from "../../registry/text" ;
2426import type { SerialProps } from "../../registry/serial" ;
@@ -168,8 +170,9 @@ function TextFieldContent({
168170 if ( obj . type !== "text" ) {
169171 return < Text key = { fontVersion } x = { shift } y = { shiftY } text = { content } { ...base } /> ;
170172 }
171- const { blockWidth, blockLines, blockJustify, blockLineSpacing, blockHangingIndent, fontHeight, fontWidth } = obj . props ;
172- if ( ! blockWidth ) {
173+ const { blockWidth, blockLines, blockHeight, blockJustify, blockLineSpacing, blockHangingIndent, fontHeight, fontWidth } = obj . props ;
174+ const mode = resolveTextMode ( obj . props ) ;
175+ if ( mode === "normal" || ! blockWidth ) {
173176 // Single-line text has no spec-defined min width, so only empty
174177 // content triggers the placeholder (block also covers too-narrow).
175178 if ( content . trim ( ) . length === 0 ) {
@@ -205,13 +208,16 @@ function TextFieldContent({
205208 dotsToPx ( blockWidth , scale , dpmm ) < measureLinePx ( firstChar ) ;
206209 const emptyContent = content . trim ( ) . length === 0 ;
207210 if ( tooNarrow || emptyContent ) {
208- const bounds = blockBoundsDots ( {
209- blockWidthDots : blockWidth ,
210- blockLines : blockLines ?? 1 ,
211- blockLineSpacing : blockLineSpacing ?? 0 ,
212- fontHeight,
213- rotation : obj . props . rotation ,
214- } ) ;
211+ const bounds =
212+ mode === "tb"
213+ ? tbBoundsDots ( blockWidth , blockHeight ?? fontHeight , obj . props . rotation )
214+ : blockBoundsDots ( {
215+ blockWidthDots : blockWidth ,
216+ blockLines : blockLines ?? 1 ,
217+ blockLineSpacing : blockLineSpacing ?? 0 ,
218+ fontHeight,
219+ rotation : obj . props . rotation ,
220+ } ) ;
215221 return (
216222 < PlaceholderRect
217223 fontVersion = { fontVersion }
@@ -223,23 +229,90 @@ function TextFieldContent({
223229 />
224230 ) ;
225231 }
232+ // Konva's getClientRect ignores a Group's clip and returns the full children
233+ // bounds, which would inflate the Transformer box past the ^TB clip. Report
234+ // the clip rect instead (the actual visible region) so the box matches.
235+ const tbClipRef = ( node : Konva . Group | null ) => {
236+ if ( ! node ) return ;
237+ node . getClientRect = ( config ?: { skipTransform ?: boolean ; relativeTo ?: Konva . Container } ) => {
238+ const local = {
239+ x : node . clipX ( ) || 0 ,
240+ y : node . clipY ( ) || 0 ,
241+ width : node . clipWidth ( ) ,
242+ height : node . clipHeight ( ) ,
243+ } ;
244+ if ( config ?. skipTransform ) return local ;
245+ const tr = config ?. relativeTo
246+ ? node . getAbsoluteTransform ( config . relativeTo )
247+ : node . getAbsoluteTransform ( ) ;
248+ const corners = [
249+ { x : local . x , y : local . y } ,
250+ { x : local . x + local . width , y : local . y } ,
251+ { x : local . x + local . width , y : local . y + local . height } ,
252+ { x : local . x , y : local . y + local . height } ,
253+ ] . map ( ( p ) => tr . point ( p ) ) ;
254+ const xs = corners . map ( ( c ) => c . x ) ;
255+ const ys = corners . map ( ( c ) => c . y ) ;
256+ const minX = Math . min ( ...xs ) ;
257+ const minY = Math . min ( ...ys ) ;
258+ return { x : minX , y : minY , width : Math . max ( ...xs ) - minX , height : Math . max ( ...ys ) - minY } ;
259+ } ;
260+ } ;
261+
262+ // ^TB: word-wrap left-aligned, no spacing/indent/justify, clipped at the
263+ // block height (Labelary truncates mid-glyph, so clip rather than drop whole
264+ // lines). At 90deg rotations the block rect stays axis-aligned.
265+ if ( mode === "tb" ) {
266+ // ^TB has no hard break; emit collapses newlines to spaces, so the canvas
267+ // must too or it would show line breaks that won't print.
268+ const tbText = content . replace ( / \n / g, " " ) ;
269+ const tbLines = wrapBlockLines ( tbText , dotsToPx ( blockWidth , scale , dpmm ) , measureLinePx ) ;
270+ const tbStep = tbLineStepDots ( fontHeight ) ;
271+ const clip = tbBoundsDots ( blockWidth , blockHeight ?? fontHeight , obj . props . rotation ) ;
272+ // Lines sit exactly like ^FB (first line at the block top with Konva's cap
273+ // pad); matches the Labelary preview so toggling it doesn't shift the text.
274+ return (
275+ < Group
276+ ref = { tbClipRef }
277+ clipX = { dotsToPx ( clip . x , scale , dpmm ) + shift }
278+ clipY = { dotsToPx ( clip . y , scale , dpmm ) + shiftY }
279+ clipWidth = { dotsToPx ( clip . width , scale , dpmm ) }
280+ clipHeight = { dotsToPx ( clip . height , scale , dpmm ) }
281+ >
282+ { tbLines . map ( ( line , i ) => {
283+ const startDots = blockLineStartDots ( i , obj . props . rotation , 0 , tbStep ) ;
284+ return (
285+ < Text
286+ key = { `${ fontVersion } -${ i } ` }
287+ x = { dotsToPx ( startDots . x , scale , dpmm ) + shift }
288+ y = { dotsToPx ( startDots . y , scale , dpmm ) + shiftY }
289+ text = { line }
290+ { ...base }
291+ />
292+ ) ;
293+ } ) }
294+ </ Group >
295+ ) ;
296+ }
226297 const justify = blockJustify ?? "L" ;
227298 const indent = blockHangingIndent ?? 0 ;
228299 const lineStepDots = blockLineStepDots ( fontHeight , blockLineSpacing ?? 0 ) ;
229- // Wrap to the rendered block width; ^FB slot b: overflow lines overprint
230- // the last line, so keep only blockLines worth.
300+ // Wrap to the rendered block width; ^FB slot b: text exceeding blockLines
301+ // overprints onto the last line (matches Labelary), so pin overflow rows to
302+ // the last index rather than dropping them.
303+ const cap = blockLines ?? 1 ;
231304 const lines = wrapBlockLines (
232305 content ,
233306 dotsToPx ( blockWidth , scale , dpmm ) ,
234307 measureLinePx ,
235- ) . slice ( 0 , blockLines ?? 1 ) ;
308+ ) . map ( ( line , i ) => ( { line , row : Math . min ( i , cap - 1 ) } ) ) ;
236309 // Labelary centers / justifies as if a trailing space is present; feed the
237310 // rendered space width so both match the preview on scaled / device fonts.
238311 const spaceWidthDots = pxToDots ( measureLinePx ( " " ) , scale , dpmm ) ;
239312 return (
240313 < >
241- { lines . flatMap ( ( line , i ) => {
242- const indentDots = zebraHangingIndentOffsetDots ( i , indent ) ;
314+ { lines . flatMap ( ( { line, row } , i ) => {
315+ const indentDots = zebraHangingIndentOffsetDots ( row , indent ) ;
243316 // Measure the rendered glyphs (same basis as the wrap) so justify
244317 // offsets match what is drawn; zebraLineWidthDots over-estimates
245318 // scaled / device fonts and would clamp the offset to 0 (stuck left).
@@ -252,7 +325,7 @@ function TextFieldContent({
252325 const words = line . split ( " " ) ;
253326 const extraGap = zebraJustifyGapDots ( lineWidthDots , effectiveBlockWidth , words . length - 1 , isLast ) ;
254327 if ( extraGap > 0 ) {
255- const startDots = blockLineStartDots ( i , obj . props . rotation , indentDots , lineStepDots ) ;
328+ const startDots = blockLineStartDots ( row , obj . props . rotation , indentDots , lineStepDots ) ;
256329 const positions = blockJustifyWordPositions ( {
257330 words, rotation : obj . props . rotation , startDots,
258331 fontHeight, fontWidth, extraGapDots : extraGap ,
@@ -271,7 +344,7 @@ function TextFieldContent({
271344 }
272345 }
273346 const alignOffsetDots = zebraAlignOffsetDots ( lineWidthDots , effectiveBlockWidth , justify , spaceWidthDots ) ;
274- const startDots = blockLineStartDots ( i , obj . props . rotation , indentDots + alignOffsetDots , lineStepDots ) ;
347+ const startDots = blockLineStartDots ( row , obj . props . rotation , indentDots + alignOffsetDots , lineStepDots ) ;
275348 return [
276349 < Text
277350 key = { `${ fontVersion } -${ i } ` }
@@ -295,6 +368,7 @@ function BlockWrapGuide({
295368 blockWidthDots,
296369 blockLines,
297370 blockLineSpacing,
371+ blockHeightDots,
298372 fontHeight,
299373 rotation,
300374 scale,
@@ -305,14 +379,19 @@ function BlockWrapGuide({
305379 blockWidthDots : number ;
306380 blockLines : number ;
307381 blockLineSpacing : number ;
382+ /** Set for ^TB: the frame is the width x clip-height rect, not line-stacked. */
383+ blockHeightDots ?: number ;
308384 fontHeight : number ;
309385 rotation : ZplRotation ;
310386 scale : number ;
311387 dpmm : number ;
312388 color : string ;
313389 frameMeasured : boolean ;
314390} ) {
315- const bounds = blockBoundsDots ( { blockWidthDots, blockLines, blockLineSpacing, fontHeight, rotation } ) ;
391+ const bounds =
392+ blockHeightDots != null
393+ ? tbBoundsDots ( blockWidthDots , blockHeightDots , rotation )
394+ : blockBoundsDots ( { blockWidthDots, blockLines, blockLineSpacing, fontHeight, rotation } ) ;
316395 const bx = dotsToPx ( bounds . x , scale , dpmm ) ;
317396 const by = dotsToPx ( bounds . y , scale , dpmm ) ;
318397 const bw = dotsToPx ( bounds . width , scale , dpmm ) ;
@@ -553,13 +632,16 @@ function KonvaObjectInner({
553632 // non-reverse path, knocked out in white. Reuse blockBoundsDots (the same
554633 // box the generator emits) and TextFieldContent so the preview matches
555634 // both the print and the normal render.
556- const bounds = blockBoundsDots ( {
557- blockWidthDots : obj . props . blockWidth ,
558- blockLines : obj . props . blockLines ?? 1 ,
559- blockLineSpacing : obj . props . blockLineSpacing ?? 0 ,
560- fontHeight : obj . props . fontHeight ,
561- rotation : p . rotation ,
562- } ) ;
635+ const bounds =
636+ resolveTextMode ( obj . props ) === "tb"
637+ ? tbBoundsDots ( obj . props . blockWidth , obj . props . blockHeight ?? obj . props . fontHeight , p . rotation )
638+ : blockBoundsDots ( {
639+ blockWidthDots : obj . props . blockWidth ,
640+ blockLines : obj . props . blockLines ?? 1 ,
641+ blockLineSpacing : obj . props . blockLineSpacing ?? 0 ,
642+ fontHeight : obj . props . fontHeight ,
643+ rotation : p . rotation ,
644+ } ) ;
563645 return (
564646 < Group
565647 id = { obj . id }
@@ -691,6 +773,7 @@ function KonvaObjectInner({
691773 blockWidthDots = { obj . props . blockWidth }
692774 blockLines = { obj . props . blockLines ?? 1 }
693775 blockLineSpacing = { obj . props . blockLineSpacing ?? 0 }
776+ blockHeightDots = { resolveTextMode ( obj . props ) === "tb" ? obj . props . blockHeight ?? obj . props . fontHeight : undefined }
694777 fontHeight = { obj . props . fontHeight }
695778 rotation = { obj . props . rotation }
696779 scale = { scale }
0 commit comments