From bd95fb4bf570472c52efe2f78725d760bd46cc68 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Mar 2026 15:49:37 +0000 Subject: [PATCH 1/3] Fix text layer resize distortion and extra bounding box space Text layers now resize by changing fontSize and maxWidth instead of scaleX/scaleY, preventing glyph distortion when stretching or shrinking. Click-to-create text no longer sets a default maxWidth of 200px, so the bounding box fits the actual text content. https://claude.ai/code/session_01R44b9C3iAQZZLpvJefkVMb --- .../components/canvas/transform-handles.tsx | 109 ++++++++++++++---- ui/src/hooks/use-canvas-interactions.ts | 2 +- 2 files changed, 88 insertions(+), 23 deletions(-) diff --git a/ui/src/components/canvas/transform-handles.tsx b/ui/src/components/canvas/transform-handles.tsx index b8720ca..5a087a8 100644 --- a/ui/src/components/canvas/transform-handles.tsx +++ b/ui/src/components/canvas/transform-handles.tsx @@ -1,6 +1,6 @@ import { useMemo, useCallback, useRef, useEffect, useState } from 'react' import { useEditorStore } from '@/store' -import type { Viewport } from '@/store/types' +import type { Viewport, TextLayer } from '@/store/types' import { findLayerById, getLayerDimensions } from '@/lib/layer-utils' interface TransformHandlesProps { @@ -45,6 +45,9 @@ interface DragState { layerHeight: number rotationRad: number snapshotPushed: boolean + isTextLayer: boolean + textFontSize: number + textMaxWidth: number | null } export function TransformHandles({ canvasRef, layerId, viewport }: TransformHandlesProps) { @@ -103,20 +106,49 @@ export function TransformHandles({ canvasRef, layerId, viewport }: TransformHand if (!layer) return - const dragDims = getLayerDimensions(layer) + const isText = layer.type === 'text' + const store = useEditorStore.getState() + + // For text layers, absorb any existing scale into fontSize/maxWidth + // so we always start the drag with scaleX=1, scaleY=1 + if (isText) { + const sx = Math.abs(layer.transform.scaleX) + const sy = Math.abs(layer.transform.scaleY) + if (sx !== 1 || sy !== 1) { + const absorbProps: Partial> = { + fontSize: layer.fontSize * sy, + } + if (layer.maxWidth !== null) { + absorbProps.maxWidth = layer.maxWidth * sx + } + store.updateTextProperties(layerId, absorbProps) + store.setTransform(layerId, { scaleX: 1, scaleY: 1 }) + } + } + + // Re-read layer after potential absorption + const currentLayer = isText + ? findLayerById(store.layers, layerId) + : layer + if (!currentLayer) return + + const dragDims = getLayerDimensions(currentLayer) dragRef.current = { handleType, handleIndex, startScreenX: e.clientX, startScreenY: e.clientY, - initialScaleX: layer.transform.scaleX, - initialScaleY: layer.transform.scaleY, - initialX: layer.transform.x, - initialY: layer.transform.y, + initialScaleX: currentLayer.transform.scaleX, + initialScaleY: currentLayer.transform.scaleY, + initialX: currentLayer.transform.x, + initialY: currentLayer.transform.y, layerWidth: dragDims.width, layerHeight: dragDims.height, - rotationRad: (layer.transform.rotation * Math.PI) / 180, + rotationRad: (currentLayer.transform.rotation * Math.PI) / 180, snapshotPushed: false, + isTextLayer: isText, + textFontSize: isText && currentLayer.type === 'text' ? currentLayer.fontSize : 0, + textMaxWidth: isText && currentLayer.type === 'text' ? currentLayer.maxWidth : null, } const onDocPointerMove = (ev: PointerEvent) => { @@ -169,40 +201,73 @@ export function TransformHandles({ canvasRef, layerId, viewport }: TransformHand const localOffX = (signX * drag.layerWidth * dsx) / 2 const localOffY = (signY * drag.layerHeight * dsy) / 2 - store.setTransform(layerId, { - scaleX: newScaleX, - scaleY: newScaleY, - x: drag.initialX + localOffX * rotCos - localOffY * rotSin, - y: drag.initialY + localOffX * rotSin + localOffY * rotCos, - }) + if (drag.isTextLayer) { + // For text: convert scale to fontSize/maxWidth, keep scale at 1 + const newFontSize = Math.max(1, drag.textFontSize * newScaleY) + const newWidth = drag.layerWidth * newScaleX + store.updateTextProperties(layerId, { + fontSize: Math.round(newFontSize * 10) / 10, + maxWidth: Math.max(1, newWidth), + }) + store.setTransform(layerId, { + scaleX: 1, + scaleY: 1, + x: drag.initialX + localOffX * rotCos - localOffY * rotSin, + y: drag.initialY + localOffX * rotSin + localOffY * rotCos, + }) + } else { + store.setTransform(layerId, { + scaleX: newScaleX, + scaleY: newScaleY, + x: drag.initialX + localOffX * rotCos - localOffY * rotSin, + y: drag.initialY + localOffX * rotSin + localOffY * rotCos, + }) + } } else { const [signX, signY, affectsX, affectsY] = MID_SIGNS[drag.handleIndex] - const updates: { scaleX?: number; scaleY?: number; x?: number; y?: number } = {} + let newScaleX = drag.initialScaleX + let newScaleY = drag.initialScaleY let localOffX = 0 let localOffY = 0 if (affectsX) { - const newScaleX = Math.max( + newScaleX = Math.max( 0.01, drag.initialScaleX + (signX * localDx) / drag.layerWidth, ) - updates.scaleX = newScaleX localOffX = (signX * drag.layerWidth * (newScaleX - drag.initialScaleX)) / 2 } if (affectsY) { - const newScaleY = Math.max( + newScaleY = Math.max( 0.01, drag.initialScaleY + (signY * localDy) / drag.layerHeight, ) - updates.scaleY = newScaleY localOffY = (signY * drag.layerHeight * (newScaleY - drag.initialScaleY)) / 2 } - updates.x = drag.initialX + localOffX * rotCos - localOffY * rotSin - updates.y = drag.initialY + localOffX * rotSin + localOffY * rotCos - - store.setTransform(layerId, updates) + const newX = drag.initialX + localOffX * rotCos - localOffY * rotSin + const newY = drag.initialY + localOffX * rotSin + localOffY * rotCos + + if (drag.isTextLayer) { + // For text: convert scale to fontSize/maxWidth, keep scale at 1 + const textProps: Partial> = {} + if (affectsX) { + textProps.maxWidth = Math.max(1, drag.layerWidth * newScaleX) + } + if (affectsY) { + textProps.fontSize = Math.round(Math.max(1, drag.textFontSize * newScaleY) * 10) / 10 + } + store.updateTextProperties(layerId, textProps) + store.setTransform(layerId, { scaleX: 1, scaleY: 1, x: newX, y: newY }) + } else { + const updates: { scaleX?: number; scaleY?: number; x?: number; y?: number } = {} + if (affectsX) updates.scaleX = newScaleX + if (affectsY) updates.scaleY = newScaleY + updates.x = newX + updates.y = newY + store.setTransform(layerId, updates) + } } } diff --git a/ui/src/hooks/use-canvas-interactions.ts b/ui/src/hooks/use-canvas-interactions.ts index c4d9d8f..056004f 100644 --- a/ui/src/hooks/use-canvas-interactions.ts +++ b/ui/src/hooks/use-canvas-interactions.ts @@ -320,7 +320,7 @@ export function useCanvasInteractions(canvasRef: React.RefObject Date: Wed, 4 Mar 2026 16:19:05 +0000 Subject: [PATCH 2/3] Format transform-handles.tsx with prettier https://claude.ai/code/session_01R44b9C3iAQZZLpvJefkVMb --- ui/src/components/canvas/transform-handles.tsx | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/ui/src/components/canvas/transform-handles.tsx b/ui/src/components/canvas/transform-handles.tsx index 5a087a8..893420c 100644 --- a/ui/src/components/canvas/transform-handles.tsx +++ b/ui/src/components/canvas/transform-handles.tsx @@ -127,9 +127,7 @@ export function TransformHandles({ canvasRef, layerId, viewport }: TransformHand } // Re-read layer after potential absorption - const currentLayer = isText - ? findLayerById(store.layers, layerId) - : layer + const currentLayer = isText ? findLayerById(store.layers, layerId) : layer if (!currentLayer) return const dragDims = getLayerDimensions(currentLayer) @@ -232,17 +230,11 @@ export function TransformHandles({ canvasRef, layerId, viewport }: TransformHand let localOffY = 0 if (affectsX) { - newScaleX = Math.max( - 0.01, - drag.initialScaleX + (signX * localDx) / drag.layerWidth, - ) + newScaleX = Math.max(0.01, drag.initialScaleX + (signX * localDx) / drag.layerWidth) localOffX = (signX * drag.layerWidth * (newScaleX - drag.initialScaleX)) / 2 } if (affectsY) { - newScaleY = Math.max( - 0.01, - drag.initialScaleY + (signY * localDy) / drag.layerHeight, - ) + newScaleY = Math.max(0.01, drag.initialScaleY + (signY * localDy) / drag.layerHeight) localOffY = (signY * drag.layerHeight * (newScaleY - drag.initialScaleY)) / 2 } From 7bae9864903d3ed0677e183799eba8b4618712b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Mar 2026 16:35:02 +0000 Subject: [PATCH 3/3] Fix text spawn position, resize behavior, and hit test measurement - Text now spawns at cursor position instead of (0,0) when clicking to create. The rect is always passed to addTextLayer; maxWidth is only set when the drag width > 0. - Resize only changes maxWidth (wrapping area), never fontSize. Font size is controlled exclusively via the properties panel. Vertical handles are no-ops for text since height is content-driven. - Fix measureTextLayer to account for letterSpacing, matching how text is rendered and improving hit test accuracy. https://claude.ai/code/session_01R44b9C3iAQZZLpvJefkVMb --- .../components/canvas/transform-handles.tsx | 28 ++++++++----------- ui/src/hooks/use-canvas-interactions.ts | 4 ++- ui/src/lib/layer-utils.ts | 3 ++ ui/src/store/slices/layers-slice.ts | 2 +- 4 files changed, 18 insertions(+), 19 deletions(-) diff --git a/ui/src/components/canvas/transform-handles.tsx b/ui/src/components/canvas/transform-handles.tsx index 893420c..d74eb85 100644 --- a/ui/src/components/canvas/transform-handles.tsx +++ b/ui/src/components/canvas/transform-handles.tsx @@ -46,8 +46,6 @@ interface DragState { rotationRad: number snapshotPushed: boolean isTextLayer: boolean - textFontSize: number - textMaxWidth: number | null } export function TransformHandles({ canvasRef, layerId, viewport }: TransformHandlesProps) { @@ -145,8 +143,6 @@ export function TransformHandles({ canvasRef, layerId, viewport }: TransformHand rotationRad: (currentLayer.transform.rotation * Math.PI) / 180, snapshotPushed: false, isTextLayer: isText, - textFontSize: isText && currentLayer.type === 'text' ? currentLayer.fontSize : 0, - textMaxWidth: isText && currentLayer.type === 'text' ? currentLayer.maxWidth : null, } const onDocPointerMove = (ev: PointerEvent) => { @@ -200,18 +196,18 @@ export function TransformHandles({ canvasRef, layerId, viewport }: TransformHand const localOffY = (signY * drag.layerHeight * dsy) / 2 if (drag.isTextLayer) { - // For text: convert scale to fontSize/maxWidth, keep scale at 1 - const newFontSize = Math.max(1, drag.textFontSize * newScaleY) + // For text: only change maxWidth (wrapping area), never fontSize. + // Vertical component is ignored — text height is determined by content. const newWidth = drag.layerWidth * newScaleX + const hLocalOffX = (signX * drag.layerWidth * dsx) / 2 store.updateTextProperties(layerId, { - fontSize: Math.round(newFontSize * 10) / 10, maxWidth: Math.max(1, newWidth), }) store.setTransform(layerId, { scaleX: 1, scaleY: 1, - x: drag.initialX + localOffX * rotCos - localOffY * rotSin, - y: drag.initialY + localOffX * rotSin + localOffY * rotCos, + x: drag.initialX + hLocalOffX * rotCos, + y: drag.initialY + hLocalOffX * rotSin, }) } else { store.setTransform(layerId, { @@ -242,16 +238,14 @@ export function TransformHandles({ canvasRef, layerId, viewport }: TransformHand const newY = drag.initialY + localOffX * rotSin + localOffY * rotCos if (drag.isTextLayer) { - // For text: convert scale to fontSize/maxWidth, keep scale at 1 - const textProps: Partial> = {} + // For text: only horizontal resize changes maxWidth. + // Vertical mid-handles are a no-op — text height is content-driven. if (affectsX) { - textProps.maxWidth = Math.max(1, drag.layerWidth * newScaleX) - } - if (affectsY) { - textProps.fontSize = Math.round(Math.max(1, drag.textFontSize * newScaleY) * 10) / 10 + store.updateTextProperties(layerId, { + maxWidth: Math.max(1, drag.layerWidth * newScaleX), + }) + store.setTransform(layerId, { scaleX: 1, scaleY: 1, x: newX, y: newY }) } - store.updateTextProperties(layerId, textProps) - store.setTransform(layerId, { scaleX: 1, scaleY: 1, x: newX, y: newY }) } else { const updates: { scaleX?: number; scaleY?: number; x?: number; y?: number } = {} if (affectsX) updates.scaleX = newScaleX diff --git a/ui/src/hooks/use-canvas-interactions.ts b/ui/src/hooks/use-canvas-interactions.ts index 056004f..71308ca 100644 --- a/ui/src/hooks/use-canvas-interactions.ts +++ b/ui/src/hooks/use-canvas-interactions.ts @@ -330,7 +330,9 @@ export function useCanvasInteractions(canvasRef: React.RefObject 0 ? rect : undefined) + // Always pass rect so text spawns at the click position. + // When width is 0, addTextLayer will set maxWidth to null (auto-width). + store.addTextLayer(rect) } store.setActiveTool('pointer') diff --git a/ui/src/lib/layer-utils.ts b/ui/src/lib/layer-utils.ts index 394237f..8c8020b 100644 --- a/ui/src/lib/layer-utils.ts +++ b/ui/src/lib/layer-utils.ts @@ -82,6 +82,9 @@ function measureTextLayer(layer: Layer & { type: 'text' }): { width: number; hei const canvas = new OffscreenCanvas(1, 1) const ctx = canvas.getContext('2d')! ctx.font = `${layer.fontStyle} ${layer.fontWeight} ${layer.fontSize}px ${layer.fontFamily}` + if ('letterSpacing' in ctx) { + ;(ctx as unknown as CanvasRenderingContext2D).letterSpacing = `${layer.letterSpacing}px` + } if (layer.maxWidth !== null) { const lines = wrapText(ctx, layer.content, layer.maxWidth) diff --git a/ui/src/store/slices/layers-slice.ts b/ui/src/store/slices/layers-slice.ts index 6097377..bef9954 100644 --- a/ui/src/store/slices/layers-slice.ts +++ b/ui/src/store/slices/layers-slice.ts @@ -230,7 +230,7 @@ export const createLayersSlice: StateCreator = textAlign: 'left', lineHeight: 1.4, letterSpacing: 0, - maxWidth: rect ? Math.abs(rect.width) : null, + maxWidth: rect && rect.width > 0 ? Math.abs(rect.width) : null, visible: true, opacity: 1, blendMode: 'normal',