Skip to content

Commit 413f39e

Browse files
zachelnetDeepSeek V4
andcommitted
fix: critical rotation bugs from senior review (C1,C2,H1-H4)
C1: sprite_collides_with_bubble_mask now rotates pixel coordinates before mask lookup. Previously treated rotated sprites as axis-aligned, causing incorrect font-size fitting for text in bubbles. Font was too large → text overflowed the bubble. C2: centred_sprite_transform computes top-left such that the rotated sprite's visual center lands at the bubble center, instead of centering the unrotated sprite then rotating around top-left. Fixes visual center drift for rotated text. H1: Range slider commits on both onPointerUp AND onBlur. Keyboard navigation (arrow keys) now reliably triggers rotation updates. H2: +/- buttons now read from currentDeg (draft-aware) instead of selectedNode directly. Prevents discarding uncommitted drafts. H3: Number input handles Escape: clears draft without committing. H4: updateTransform wrapped in useCallback([page, applyOp, queueAutoRender]) to avoid stale closure during drag. Co-authored-by: DeepSeek V4 <deepseek@v4.ai>
1 parent dd5f955 commit 413f39e

3 files changed

Lines changed: 63 additions & 43 deletions

File tree

crates/koharu-app/src/renderer.rs

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -712,12 +712,18 @@ fn sprite_collides_with_bubble_mask(
712712
let mask_w = mask.width() as i32;
713713
let mask_h = mask.height() as i32;
714714

715+
let rotation_rad = transform.rotation_deg.to_radians();
716+
let cos = rotation_rad.cos();
717+
let sin = rotation_rad.sin();
718+
715719
for (x, y, pixel) in sprite.enumerate_pixels() {
716720
if pixel.0[3] <= MASK_COLLISION_ALPHA_THRESHOLD {
717721
continue;
718722
}
719-
let mask_x = origin_x + x as i32;
720-
let mask_y = origin_y + y as i32;
723+
let fx = x as f32;
724+
let fy = y as f32;
725+
let mask_x = origin_x + (cos * fx - sin * fy).round() as i32;
726+
let mask_y = origin_y + (sin * fx + cos * fy).round() as i32;
721727
if mask_x < 0 || mask_y < 0 || mask_x >= mask_w || mask_y >= mask_h {
722728
return true;
723729
}
@@ -993,9 +999,19 @@ fn centred_sprite_transform(
993999
let sprite_h = sprite_height as f32;
9941000
let cx = anchor_box.x + anchor_box.width * 0.5;
9951001
let cy = anchor_box.y + anchor_box.height * 0.5;
1002+
1003+
// The sprite is rotated around its top-left corner (CSS transform-origin model).
1004+
// Compute the top-left such that the sprite's visual center lands at (cx, cy).
1005+
let hw = sprite_w * 0.5;
1006+
let hh = sprite_h * 0.5;
1007+
let cos = rotation_deg.to_radians().cos();
1008+
let sin = rotation_deg.to_radians().sin();
1009+
let rotated_center_x = cos * hw - sin * hh;
1010+
let rotated_center_y = sin * hw + cos * hh;
1011+
9961012
Transform {
997-
x: (cx - sprite_w * 0.5).round(),
998-
y: (cy - sprite_h * 0.5).round(),
1013+
x: (cx - rotated_center_x).round(),
1014+
y: (cy - rotated_center_y).round(),
9991015
width: sprite_w,
10001016
height: sprite_h,
10011017
rotation_deg,

ui/components/canvas/TextBlockLayer.tsx

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client'
22

33
import { useDrag } from '@use-gesture/react'
4-
import { useEffect, useRef } from 'react'
4+
import { useCallback, useEffect, useRef } from 'react'
55

66
import { useBlobImage } from '@/hooks/useBlobData'
77
import { useCurrentPage, useTextNodes, type TextNodeEntry } from '@/hooks/useCurrentPage'
@@ -50,24 +50,27 @@ export function TextBlockLayer({ showSprites, scale, style }: TextBlockLayerProp
5050
const mode = useEditorUiStore((s) => s.mode)
5151
const interactive = mode === 'select' || mode === 'block'
5252

53-
const updateTransform = async (id: string, geometry: BoxGeometry) => {
54-
if (!page) return
55-
const rotationDeg = page.nodes[id]?.transform?.rotationDeg ?? 0
56-
const next: Transform = {
57-
x: geometry.x,
58-
y: geometry.y,
59-
width: geometry.width,
60-
height: geometry.height,
61-
rotationDeg,
62-
}
63-
const data: NodeDataPatch = {
64-
text: {
65-
lockLayoutBox: true,
66-
},
67-
}
68-
await applyOp(ops.updateNode(page.id, id, { transform: next, data }))
69-
queueAutoRender(page.id)
70-
}
53+
const updateTransform = useCallback(
54+
async (id: string, geometry: BoxGeometry) => {
55+
if (!page) return
56+
const rotationDeg = page.nodes[id]?.transform?.rotationDeg ?? 0
57+
const next: Transform = {
58+
x: geometry.x,
59+
y: geometry.y,
60+
width: geometry.width,
61+
height: geometry.height,
62+
rotationDeg,
63+
}
64+
const data: NodeDataPatch = {
65+
text: {
66+
lockLayoutBox: true,
67+
},
68+
}
69+
await applyOp(ops.updateNode(page.id, id, { transform: next, data }))
70+
queueAutoRender(page.id)
71+
},
72+
[page, applyOp, queueAutoRender],
73+
)
7174

7275
return (
7376
<div

ui/components/panels/RenderControlsPanel.tsx

Lines changed: 21 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,20 @@ export function RenderControlsPanel() {
379379
setRotationDraft(null)
380380
}, [selectedNode?.id, selectedNode?.transform.rotationDeg])
381381

382+
const commitRotationDraft = () => {
383+
if (rotationDraft == null) return
384+
const parsed = Number.parseFloat(rotationDraft)
385+
setRotationDraft(null)
386+
if (!Number.isFinite(parsed)) return
387+
void updateSelectedRotation(parsed)
388+
}
389+
390+
// Current rotation value, preferring the draft when it's a valid number.
391+
const currentDeg =
392+
rotationDraft != null && Number.isFinite(Number.parseFloat(rotationDraft))
393+
? clampRotationDeg(Number.parseFloat(rotationDraft))
394+
: (selectedNode?.transform.rotationDeg ?? 0)
395+
382396
const effectItems: {
383397
key: 'italic' | 'bold'
384398
label: string
@@ -816,9 +830,7 @@ export function RenderControlsPanel() {
816830
className='size-7 shrink-0 rounded-r-none border-r'
817831
data-testid='render-rotation-minus'
818832
disabled={!selectedNode}
819-
onClick={() =>
820-
void updateSelectedRotation((selectedNode?.transform.rotationDeg ?? 0) - 1)
821-
}
833+
onClick={() => void updateSelectedRotation(currentDeg - 1)}
822834
>
823835
<MinusIcon className='size-3' />
824836
</Button>
@@ -842,13 +854,8 @@ export function RenderControlsPanel() {
842854
onChange={(event) => {
843855
setRotationDraft(event.target.value)
844856
}}
845-
onPointerUp={() => {
846-
if (rotationDraft == null) return
847-
const parsed = Number.parseFloat(rotationDraft)
848-
setRotationDraft(null)
849-
if (!Number.isFinite(parsed)) return
850-
void updateSelectedRotation(parsed)
851-
}}
857+
onPointerUp={commitRotationDraft}
858+
onBlur={commitRotationDraft}
852859
className='h-7 min-w-0 flex-1 cursor-pointer accent-primary'
853860
/>
854861

@@ -864,16 +871,12 @@ export function RenderControlsPanel() {
864871
onChange={(event) => {
865872
setRotationDraft(event.target.value)
866873
}}
867-
onBlur={() => {
868-
if (rotationDraft == null) return
869-
const parsed = Number.parseFloat(rotationDraft)
870-
setRotationDraft(null)
871-
if (!Number.isFinite(parsed)) return
872-
void updateSelectedRotation(parsed)
873-
}}
874+
onBlur={commitRotationDraft}
874875
onKeyDown={(event) => {
875876
if (event.key === 'Enter') {
876877
(event.target as HTMLInputElement).blur()
878+
} else if (event.key === 'Escape') {
879+
setRotationDraft(null)
877880
}
878881
}}
879882
/>
@@ -887,9 +890,7 @@ export function RenderControlsPanel() {
887890
className='size-7 shrink-0 rounded-l-none border-l'
888891
data-testid='render-rotation-plus'
889892
disabled={!selectedNode}
890-
onClick={() =>
891-
void updateSelectedRotation((selectedNode?.transform.rotationDeg ?? 0) + 1)
892-
}
893+
onClick={() => void updateSelectedRotation(currentDeg + 1)}
893894
>
894895
<PlusIcon className='size-3' />
895896
</Button>

0 commit comments

Comments
 (0)