diff --git a/ui/src/components/canvas/transform-handles.tsx b/ui/src/components/canvas/transform-handles.tsx index b8720ca..d74eb85 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,7 @@ interface DragState { layerHeight: number rotationRad: number snapshotPushed: boolean + isTextLayer: boolean } export function TransformHandles({ canvasRef, layerId, viewport }: TransformHandlesProps) { @@ -103,20 +104,45 @@ 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, } const onDocPointerMove = (ev: PointerEvent) => { @@ -169,40 +195,65 @@ 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: 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, { + maxWidth: Math.max(1, newWidth), + }) + store.setTransform(layerId, { + scaleX: 1, + scaleY: 1, + x: drag.initialX + hLocalOffX * rotCos, + y: drag.initialY + hLocalOffX * rotSin, + }) + } 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( - 0.01, - drag.initialScaleX + (signX * localDx) / drag.layerWidth, - ) - updates.scaleX = newScaleX + newScaleX = Math.max(0.01, drag.initialScaleX + (signX * localDx) / drag.layerWidth) localOffX = (signX * drag.layerWidth * (newScaleX - drag.initialScaleX)) / 2 } if (affectsY) { - const newScaleY = Math.max( - 0.01, - drag.initialScaleY + (signY * localDy) / drag.layerHeight, - ) - updates.scaleY = newScaleY + newScaleY = Math.max(0.01, drag.initialScaleY + (signY * localDy) / drag.layerHeight) 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: only horizontal resize changes maxWidth. + // Vertical mid-handles are a no-op — text height is content-driven. + if (affectsX) { + store.updateTextProperties(layerId, { + maxWidth: Math.max(1, drag.layerWidth * newScaleX), + }) + 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..71308ca 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 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',