Skip to content

Commit 33d92ad

Browse files
committed
Preserve signed shape and image dimensions
1 parent 713bdfb commit 33d92ad

4 files changed

Lines changed: 202 additions & 72 deletions

File tree

app/lib/handlers/shape.dart

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -23,22 +23,8 @@ class ShapeHandler extends PastingHandler<ShapeTool> with ColoredHandler {
2323

2424
return [
2525
ShapeElement(
26-
firstPosition:
27-
(data.property.shape is LineShape
28-
? rect.topLeft
29-
: rect.topLeft.translate(
30-
min(0, rect.width),
31-
min(0, rect.height),
32-
))
33-
.toPoint(),
34-
secondPosition:
35-
(data.property.shape is LineShape
36-
? rect.bottomRight
37-
: rect.bottomRight.translate(
38-
max(0, -rect.width),
39-
max(0, -rect.height),
40-
))
41-
.toPoint(),
26+
firstPosition: rect.topLeft.toPoint(),
27+
secondPosition: rect.bottomRight.toPoint(),
4228
property: data.property.copyWith(
4329
strokeWidth:
4430
data.property.strokeWidth /

app/lib/renderers/elements/image.dart

Lines changed: 56 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,44 @@ class ImageRenderer extends Renderer<ImageElement> {
1111
this.ownsImage = true,
1212
]);
1313

14+
Offset get _signedSize {
15+
final constraints = element.constraints;
16+
var width = element.width;
17+
var height = element.height;
18+
if (constraints is ScaledElementConstraints) {
19+
width *= constraints.scaleX == 0 ? 1 : constraints.scaleX;
20+
height *= constraints.scaleY == 0 ? 1 : constraints.scaleY;
21+
} else if (constraints is FixedElementConstraints) {
22+
width = constraints.width == 0 ? width : constraints.width;
23+
height = constraints.height == 0 ? height : constraints.height;
24+
} else if (constraints is DynamicElementConstraints) {
25+
width = constraints.width;
26+
height = constraints.height;
27+
final ratio = constraints.aspectRatio;
28+
if (ratio != 0) {
29+
if (width == 0) width = height * ratio;
30+
if (height == 0) height = width / ratio;
31+
}
32+
if (constraints.includeArea) {
33+
final areaRect = area?.rect;
34+
if (areaRect == null) {
35+
width = element.width;
36+
height = element.height;
37+
} else {
38+
final right = element.position.x + element.width;
39+
final areaWidth = min(areaRect.right, right) - element.position.x;
40+
width = areaWidth <= 0 ? element.width : areaWidth;
41+
final bottom = element.position.y + element.height;
42+
final areaHeight = min(areaRect.bottom, bottom) - element.position.y;
43+
height = areaHeight <= 0 ? element.height : areaHeight;
44+
}
45+
}
46+
if (width == 0) width = element.width;
47+
if (height == 0) height = element.height;
48+
}
49+
return Offset(width, height);
50+
}
51+
1452
@override
1553
bool onAssetUpdate(
1654
NoteData document,
@@ -54,12 +92,17 @@ class ImageRenderer extends Renderer<ImageElement> {
5492
..filterQuality = FilterQuality.medium
5593
..isAntiAlias = true;
5694

95+
final signedSize = _signedSize;
96+
canvas.save();
97+
canvas.translate(element.position.x, element.position.y);
98+
canvas.scale(signedSize.dx < 0 ? -1 : 1, signedSize.dy < 0 ? -1 : 1);
5799
canvas.drawImageRect(
58100
image!,
59-
Rect.fromLTWH(0, 0, element.width.toDouble(), element.height.toDouble()),
60-
rect,
101+
Rect.fromLTWH(0, 0, element.width, element.height),
102+
Rect.fromLTWH(0, 0, signedSize.dx.abs(), signedSize.dy.abs()),
61103
paint,
62104
);
105+
canvas.restore();
63106
}
64107

65108
@override
@@ -72,6 +115,14 @@ class ImageRenderer extends Renderer<ImageElement> {
72115
if (!rect.overlaps(viewportRect)) return;
73116
// Create data url
74117
final data = element.getUriData(document, 'image/png').toString();
118+
final signedSize = _signedSize;
119+
final flipX = signedSize.dx < 0;
120+
final flipY = signedSize.dy < 0;
121+
final svgTransform = flipX || flipY
122+
? 'translate(${flipX ? rect.left + rect.right : 0} '
123+
'${flipY ? rect.top + rect.bottom : 0}) '
124+
'scale(${flipX ? -1 : 1} ${flipY ? -1 : 1})'
125+
: null;
75126
// Create image
76127
xml
77128
.getElement('svg')
@@ -83,6 +134,7 @@ class ImageRenderer extends Renderer<ImageElement> {
83134
'width': '${rect.width}px',
84135
'height': '${rect.height}px',
85136
'xlink:href': data,
137+
'transform': ?svgTransform,
86138
},
87139
);
88140
}
@@ -119,60 +171,8 @@ class ImageRenderer extends Renderer<ImageElement> {
119171

120172
@override
121173
Rect get rect {
122-
final constraints = element.constraints;
123-
if (constraints is ScaledElementConstraints) {
124-
final scaleX = constraints.scaleX <= 0 ? 1 : constraints.scaleX;
125-
final scaleY = constraints.scaleY <= 0 ? 1 : constraints.scaleY;
126-
return Rect.fromLTWH(
127-
element.position.x,
128-
element.position.y,
129-
(element.width * scaleX).toDouble(),
130-
(element.height * scaleY).toDouble(),
131-
);
132-
} else if (constraints is FixedElementConstraints) {
133-
var height = constraints.height;
134-
var width = constraints.width;
135-
if (height <= 0) height = element.height.toDouble();
136-
if (width <= 0) width = element.width.toDouble();
137-
return Rect.fromLTWH(
138-
element.position.x,
139-
element.position.y,
140-
width,
141-
height,
142-
);
143-
} else if (constraints is DynamicElementConstraints) {
144-
var width = constraints.width;
145-
var height = constraints.height;
146-
final ratio = constraints.aspectRatio;
147-
if (ratio != 0) {
148-
if (width <= 0) width = height * ratio;
149-
if (height <= 0) height = width / ratio;
150-
}
151-
if (constraints.includeArea) {
152-
final areaRect = area?.rect;
153-
final rightArea = areaRect?.right ?? 0;
154-
final right = element.position.x + element.width;
155-
width = min(rightArea, right) - element.position.x;
156-
final bottomArea = areaRect?.bottom ?? 0;
157-
final bottom = element.position.y + element.height;
158-
height = min(bottomArea, bottom) - element.position.y;
159-
}
160-
if (height <= 0) height = element.height.toDouble();
161-
if (width <= 0) width = element.width.toDouble();
162-
return Rect.fromLTWH(
163-
element.position.x,
164-
element.position.y,
165-
width,
166-
height,
167-
);
168-
} else {
169-
return Rect.fromLTWH(
170-
element.position.x,
171-
element.position.y,
172-
element.width.toDouble(),
173-
element.height.toDouble(),
174-
);
175-
}
174+
final position = element.position.toOffset();
175+
return Rect.fromPoints(position, position + _signedSize);
176176
}
177177

178178
@override

app/test/handlers/shape_handler_test.dart

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,27 @@ void main() {
3838
isNotEmpty,
3939
);
4040
});
41+
42+
test('triangle creation preserves negative drag dimensions', () {
43+
final handler = ShapeHandler(
44+
ShapeTool(property: const ShapeProperty(shape: TriangleShape())),
45+
);
46+
final cubit = MockEditorController();
47+
48+
final element =
49+
handler
50+
.transformElements(
51+
const Rect.fromLTRB(100, 80, 10, 20),
52+
'',
53+
cubit,
54+
)
55+
.single
56+
as ShapeElement;
57+
58+
expect(element.firstPosition.x, 100);
59+
expect(element.firstPosition.y, 80);
60+
expect(element.secondPosition.x, 10);
61+
expect(element.secondPosition.y, 20);
62+
expect(element.rotation, 0);
63+
});
4164
}

app/test/renderers/image_renderer_test.dart

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
1+
import 'dart:math';
2+
import 'dart:typed_data';
13
import 'dart:ui' as ui;
24

5+
import 'package:archive/archive.dart';
36
import 'package:butterfly/bloc/document_bloc.dart';
47
import 'package:butterfly/cubits/editor_controller.dart';
58
import 'package:butterfly/cubits/transform.dart';
9+
import 'package:butterfly/models/viewport.dart';
610
import 'package:butterfly/renderers/renderer.dart';
11+
import 'package:butterfly/view_painter.dart';
712
import 'package:butterfly_api/butterfly_api.dart';
813
import 'package:flutter_test/flutter_test.dart';
914
import 'package:mocktail/mocktail.dart';
@@ -24,6 +29,60 @@ Future<ui.Image> _createImage() async {
2429
}
2530
}
2631

32+
Future<ui.Image> _createStripImage() async {
33+
final recorder = ui.PictureRecorder();
34+
ui.Canvas(recorder)
35+
..drawRect(
36+
const ui.Rect.fromLTWH(0, 0, 5, 1),
37+
ui.Paint()..color = const ui.Color(0xFFFF0000),
38+
)
39+
..drawRect(
40+
const ui.Rect.fromLTWH(5, 0, 5, 1),
41+
ui.Paint()..color = const ui.Color(0xFF0000FF),
42+
);
43+
final picture = recorder.endRecording();
44+
try {
45+
return await picture.toImage(10, 1);
46+
} finally {
47+
picture.dispose();
48+
}
49+
}
50+
51+
Future<Uint8List> _render(ImageRenderer renderer) async {
52+
final recorder = ui.PictureRecorder();
53+
final canvas = ui.Canvas(recorder);
54+
ViewPainter(
55+
NoteData(Archive()),
56+
const DocumentPage(),
57+
const DocumentInfo(),
58+
cameraViewport: CameraViewport.unbaked(
59+
unbakedElements: [renderer],
60+
visibleElements: [renderer],
61+
visibleUnbakedElements: [renderer],
62+
),
63+
).paint(canvas, const ui.Size(10, 1));
64+
final picture = recorder.endRecording();
65+
ui.Image? image;
66+
try {
67+
image = await picture.toImage(10, 1);
68+
final data = await image.toByteData(format: ui.ImageByteFormat.rawRgba);
69+
return data!.buffer.asUint8List();
70+
} finally {
71+
image?.dispose();
72+
picture.dispose();
73+
}
74+
}
75+
76+
ui.Color _pixel(Uint8List pixels, int x) {
77+
final offset = x * 4;
78+
return ui.Color.fromARGB(
79+
pixels[offset + 3],
80+
pixels[offset],
81+
pixels[offset + 1],
82+
pixels[offset + 2],
83+
);
84+
}
85+
2786
void main() {
2887
TestWidgetsFlutterBinding.ensureInitialized();
2988

@@ -48,4 +107,66 @@ void main() {
48107
renderer.dispose();
49108
},
50109
);
110+
111+
test('negative image scale keeps mirrored bounds', () {
112+
final renderer = ImageRenderer(
113+
ImageElement(source: 'test.png', width: 100, height: 50),
114+
);
115+
116+
final mirrored = renderer.transform(scaleX: -1)!;
117+
118+
expect(mirrored.rotation, 0);
119+
expect(mirrored.rect, const ui.Rect.fromLTWH(-100, 0, 100, 50));
120+
expect(
121+
(mirrored.element.constraints as ScaledElementConstraints).scaleX,
122+
-1,
123+
);
124+
});
125+
126+
test('flipped rotated image stays in the target bounds', () {
127+
final renderer = ImageRenderer(
128+
ImageElement(
129+
source: 'test.png',
130+
width: 100,
131+
height: 50,
132+
position: const Point(100, 100),
133+
rotation: 30,
134+
),
135+
);
136+
final originalBounds = renderer.expandedRect!;
137+
final targetBounds = originalBounds.shift(
138+
ui.Offset(-originalBounds.width, 0),
139+
);
140+
141+
final mirrored = renderer.transform(
142+
position: ui.Offset(-originalBounds.width, 0),
143+
scaleX: -1,
144+
positionIsBounds: true,
145+
)!;
146+
final mirroredBounds = mirrored.expandedRect!;
147+
148+
expect(mirrored.rotation, closeTo(330, 1e-9));
149+
expect(mirroredBounds.left, closeTo(targetBounds.left, 1e-9));
150+
expect(mirroredBounds.top, closeTo(targetBounds.top, 1e-9));
151+
expect(mirroredBounds.width, closeTo(targetBounds.width, 1e-9));
152+
expect(mirroredBounds.height, closeTo(targetBounds.height, 1e-9));
153+
});
154+
155+
test('negative image scale mirrors pixels', () async {
156+
final image = await _createStripImage();
157+
final renderer = ImageRenderer(
158+
ImageElement(source: 'test.png', width: 10, height: 1),
159+
null,
160+
image,
161+
);
162+
final mirrored =
163+
renderer.transform(position: const ui.Offset(10, 0), scaleX: -1)!
164+
as ImageRenderer;
165+
166+
final pixels = await _render(mirrored);
167+
168+
expect(_pixel(pixels, 2), const ui.Color(0xFF0000FF));
169+
expect(_pixel(pixels, 7), const ui.Color(0xFFFF0000));
170+
renderer.dispose();
171+
});
51172
}

0 commit comments

Comments
 (0)