Skip to content
Merged
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## 12.0.8
- **PERF**(paint-editor): Optimize freestyle path building by reducing redundant `moveTo` calls, eliminating intermediate list allocations, and using `distanceSquared` instead of `distance`.
- **PERF**(paint-editor): Skip `Opacity` widget wrapping when layer opacity is 1.0.
- **PERF**(paint-editor): Replace O(N×M) layer filtering in `done()` with Map-based O(1) lookup.
- **PERF**(main-editor): Replace O(N²) layer copy loop when closing paint editor with single deep-copy and incremental shallow snapshots.
- **PERF**(main-editor): Batch history entries without redundant `updateActiveItems()` calls via new `skipUpdateActiveItems` parameter on `addHistory()`.

## 12.0.7
- **FEAT**(text-editor): Add `leadingDistribution` property to `TextEditorStyle` for configuring how extra line height is distributed. Use `TextLeadingDistribution.even` to vertically center text within rounded background rects at non-default line heights. Defaults to `proportional` for backward compatibility.

Expand Down
48 changes: 39 additions & 9 deletions lib/features/main_editor/main_editor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1669,29 +1669,59 @@ class ProImageEditorState extends State<ProImageEditor>

if (result == null) return;

// Deep-copy active layers ONCE, then build history entries incrementally.
// This avoids the O(N²) cost of copyLayerList on every addLayer call.
final runningLayers = _layerCopyManager.copyLayerList(activeLayers);
final historyLimit = stateHistoryConfigs.stateHistoryLimit;
final enableScreenshotLimit =
imageGenerationConfigs.enableBackgroundGeneration;

String lastLayerId = '';
for (var i = 0; i < result.layers.length; i++) {
final layer = result.layers[i];
final oldIndex = activeLayers.indexWhere((el) => el.id == layer.id);
final oldIndex = runningLayers.indexWhere((el) => el.id == layer.id);

final duplicatedLayer = _layerCopyManager.duplicateLayer(
layer,
offset: Offset.zero,
);
lastLayerId = duplicatedLayer.id;
addLayer(
duplicatedLayer,
removeLayerIndex: oldIndex,
blockSelectLayer: true,
blockCaptureScreenshot: true,
autoCorrectZoomOffset: false,
autoCorrectZoomScale: false,

if (oldIndex >= 0) {
runningLayers.removeAt(oldIndex);
}
runningLayers.add(duplicatedLayer);

// Add individual history entry (for per-layer undo) with a shallow
// snapshot — the layer objects themselves are already independent copies.
stateManager.addHistory(
EditorStateHistory(layers: List<Layer>.of(runningLayers)),
historyLimit: historyLimit,
enableScreenshotLimit: enableScreenshotLimit,
skipUpdateActiveItems: true,
);
_controllers.screenshot.addEmptyScreenshot(
screenshots: stateManager.screenshots,
);
}

for (Layer layer in result.removedLayers) {
removeLayer(layer, blockCaptureScreenshot: true);
final layerPos = runningLayers.indexWhere((el) => el.id == layer.id);
if (layerPos < 0) continue;
runningLayers.removeAt(layerPos);
stateManager.addHistory(
EditorStateHistory(layers: List<Layer>.of(runningLayers)),
historyLimit: historyLimit,
enableScreenshotLimit: enableScreenshotLimit,
skipUpdateActiveItems: true,
);
_controllers.screenshot.addEmptyScreenshot(
screenshots: stateManager.screenshots,
);
}

stateManager.updateActiveItems();

if (lastLayerId.isNotEmpty) {
_selectLayerAfterHeroIsDone(lastLayerId);
}
Expand Down
3 changes: 2 additions & 1 deletion lib/features/main_editor/services/state_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -282,12 +282,13 @@ class StateManager {
EditorStateHistory history, {
int historyLimit = 1000,
bool enableScreenshotLimit = true,
bool skipUpdateActiveItems = false,
}) {
_cleanForwardChanges();
_stateHistory.add(history);
historyPointer = _stateHistory.length - 1;
setHistoryLimit(historyLimit, enableScreenshotLimit);
updateActiveItems();
if (!skipUpdateActiveItems) updateActiveItems();
}

/// Redoes the last undone change, moving the history pointer forward by one
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,40 +24,46 @@ class PathBuilderFreestyle extends PathBuilderBase {
item.mode == PaintMode.freeStyleArrowStartEnd;

final double dotRadius = painter.strokeWidth / 2;
final int len = offsets.length;

final scaled = List<Offset?>.generate(
offsets.length,
(i) => offsets[i] == null
? null
: Offset(offsets[i]!.dx * scale, offsets[i]!.dy * scale),
growable: false,
);

for (int i = 0; i < scaled.length - 1; i++) {
final a = scaled[i];
final b = scaled[i + 1];

if (a != null && b != null) {
path
..moveTo(a.dx, a.dy)
..lineTo(b.dx, b.dy);
} else if (a != null && b == null) {
// Add tiny circle at the dot point
path.addOval(Rect.fromCircle(center: a, radius: dotRadius));
// Build path with minimal moveTo calls by tracking continuous segments.
// Only emit moveTo at the start of each segment, then lineTo for the rest.
bool needsMoveTo = true;

for (int i = 0; i < len; i++) {
final raw = offsets[i];
if (raw == null) {
needsMoveTo = true;
continue;
}

final double sx = raw.dx * scale;
final double sy = raw.dy * scale;

if (needsMoveTo) {
// Isolated dot: point at end of list or followed by null
if (i + 1 >= len || offsets[i + 1] == null) {
path.addOval(
Rect.fromCircle(center: Offset(sx, sy), radius: dotRadius),
);
} else {
path.moveTo(sx, sy);
needsMoveTo = false;
}
} else {
path.lineTo(sx, sy);
}
}

// Add arrowheads if needed
if (hasArrowStart || hasArrowEnd) {
// Scale arrow size based on strokeWidth for consistent proportions
final strokeFactor = painter.strokeWidth / 2;
// Minimum distance for stable direction calculation
final minDistance = 20.0 * scale;
// Use squared distance to avoid sqrt in comparisons
final minDistanceSq = 400.0 * scale * scale;

if (hasArrowStart) {
final (startPoint, directionPoint) = _findPointsWithMinDistance(
scaled,
minDistance,
minDistanceSq,
fromStart: true,
);
if (startPoint != null && directionPoint != null) {
Expand All @@ -67,8 +73,7 @@ class PathBuilderFreestyle extends PathBuilderBase {

if (hasArrowEnd) {
final (endPoint, directionPoint) = _findPointsWithMinDistance(
scaled,
minDistance,
minDistanceSq,
fromStart: false,
);
if (endPoint != null && directionPoint != null) {
Expand All @@ -78,57 +83,54 @@ class PathBuilderFreestyle extends PathBuilderBase {
}

painter.strokeCap = StrokeCap.round;
painter.strokeJoin = StrokeJoin.round;

return path;
}

/// Finds two points with a minimum distance for stable direction calculation.
///
/// Uses squared distance to avoid sqrt. Scales offsets inline to avoid
/// allocating a separate scaled list.
/// If [fromStart] is true, searches from the beginning of the list.
/// Returns a tuple of (anchor point, direction point).
(Offset?, Offset?) _findPointsWithMinDistance(
List<Offset?> points,
double minDistance, {
double minDistanceSq, {
required bool fromStart,
}) {
Offset? anchorPoint;
Offset? directionPoint;
final points = offsets;

if (fromStart) {
// Find first non-null point as anchor
for (int i = 0; i < points.length; i++) {
if (points[i] != null) {
anchorPoint = points[i];
// Find a point with sufficient distance
anchorPoint = points[i]! * scale;
for (int j = i + 1; j < points.length; j++) {
if (points[j] != null) {
final distance = (points[j]! - anchorPoint!).distance;
if (distance >= minDistance) {
directionPoint = points[j];
final scaled = points[j]! * scale;
if ((scaled - anchorPoint).distanceSquared >= minDistanceSq) {
directionPoint = scaled;
break;
}
// Keep updating to at least have the furthest point found
directionPoint = points[j];
directionPoint = scaled;
}
}
break;
}
}
} else {
// Find last non-null point as anchor
for (int i = points.length - 1; i >= 0; i--) {
if (points[i] != null) {
anchorPoint = points[i];
// Find a point with sufficient distance
anchorPoint = points[i]! * scale;
for (int j = i - 1; j >= 0; j--) {
if (points[j] != null) {
final distance = (points[j]! - anchorPoint!).distance;
if (distance >= minDistance) {
directionPoint = points[j];
final scaled = points[j]! * scale;
if ((scaled - anchorPoint).distanceSquared >= minDistanceSq) {
directionPoint = scaled;
break;
}
// Keep updating to at least have the furthest point found
directionPoint = points[j];
directionPoint = scaled;
}
}
break;
Expand Down
21 changes: 11 additions & 10 deletions lib/features/paint_editor/paint_editor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -664,22 +664,23 @@ class PaintEditorState extends State<PaintEditor>
},
onCloseWithValue: () {
if (!canUndo) return Navigator.pop(context);

final scale = _layerStackTransformHelper.scale;

final originalLayers = (widget.initConfigs.layers ?? [])
.whereType<PaintLayer>()
.toList();
// Build a map for O(1) lookup instead of O(N) indexWhere
final originalErasedMap = <String, List<dynamic>>{};
for (final layer
in (widget.initConfigs.layers ?? []).whereType<PaintLayer>()) {
originalErasedMap[layer.id] = layer.item.erasedOffsets;
}

final newLayers = activeHistory.layers.whereType<PaintLayer>().where((
layer,
) {
return originalLayers.indexWhere(
(el) =>
el.id == layer.id &&
listEquals(el.item.erasedOffsets, layer.item.erasedOffsets),
) <
0;
final originalErased = originalErasedMap[layer.id];
if (originalErased == null) return true;
return !listEquals(originalErased, layer.item.erasedOffsets);
});

final transformedLayers = newLayers.map((layer) {
return layer
..offset *= scale
Expand Down
29 changes: 15 additions & 14 deletions lib/shared/widgets/layer/widgets/layer_widget_paint_item.dart
Original file line number Diff line number Diff line change
Expand Up @@ -44,22 +44,23 @@ class LayerWidgetPaintItem extends StatelessWidget {

@override
Widget build(BuildContext context) {
return Opacity(
opacity: layer.opacity,
child: CustomPaint(
size: layer.size,
willChange: willChange,
isComplex: layer.item.mode.isFreeStyleMode,
painter: DrawPaintItem(
item: layer.item,
scale: layer.scale,
selected: isSelected,
enabledHitDetection: enableHitDetection,
onHitChanged: onHitChanged,
paintEditorConfigs: paintEditorConfigs,
),
final child = CustomPaint(
size: layer.size,
willChange: willChange,
isComplex: layer.item.mode.isFreeStyleMode,
painter: DrawPaintItem(
item: layer.item,
scale: layer.scale,
selected: isSelected,
enabledHitDetection: enableHitDetection,
onHitChanged: onHitChanged,
paintEditorConfigs: paintEditorConfigs,
),
);

if (layer.opacity >= 1.0) return child;

return Opacity(opacity: layer.opacity, child: child);
}

@override
Expand Down
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: pro_image_editor
description: "A Flutter image editor: Seamlessly enhance your images with user-friendly editing features."
version: 12.0.7
version: 12.0.8
homepage: https://github.com/hm21/pro_image_editor/
repository: https://github.com/hm21/pro_image_editor/
documentation: https://github.com/hm21/pro_image_editor/
Expand Down
Loading