Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 79 additions & 28 deletions ui/src/components/canvas/transform-handles.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -45,6 +45,7 @@ interface DragState {
layerHeight: number
rotationRad: number
snapshotPushed: boolean
isTextLayer: boolean
}

export function TransformHandles({ canvasRef, layerId, viewport }: TransformHandlesProps) {
Expand Down Expand Up @@ -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<Pick<TextLayer, 'fontSize' | 'maxWidth'>> = {
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) => {
Expand Down Expand Up @@ -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)
}
}
}

Expand Down
6 changes: 4 additions & 2 deletions ui/src/hooks/use-canvas-interactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@ export function useCanvasInteractions(canvasRef: React.RefObject<HTMLCanvasEleme
if (rect.width < MIN_DRAW_SIZE && rect.height < MIN_DRAW_SIZE) {
rect.x = ptrRef.current.startWX
rect.y = ptrRef.current.startWY
rect.width = 200
rect.width = tool === 'draw-text' ? 0 : 200
rect.height = tool === 'draw-text' ? 0 : 200
}

Expand All @@ -330,7 +330,9 @@ export function useCanvasInteractions(canvasRef: React.RefObject<HTMLCanvasEleme
} else if (tool === 'draw-ellipse') {
store.addShapeLayer('ellipse', rect)
} else if (tool === 'draw-text') {
store.addTextLayer(rect.width > 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')
Expand Down
3 changes: 3 additions & 0 deletions ui/src/lib/layer-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion ui/src/store/slices/layers-slice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ export const createLayersSlice: StateCreator<EditorState, [], [], LayersSlice> =
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',
Expand Down