Skip to content

feat(layers): per-layer move and scale limits - #910

Merged
hm21 merged 2 commits into
stablefrom
feat/layer-interaction-limits
Oct 31, 2026
Merged

hm21 merged 2 commits into
stablefrom
feat/layer-interaction-limits

Conversation

@hm21

@hm21 hm21 commented Oct 31, 2026

Copy link
Copy Markdown
Owner

Description

Problem. A host had no way to keep one layer inside part of the canvas, or to give one layer its own scale range. Divine's poll, quiz and slider stickers are tap targets in the published video, so they must stay clear of the feed's buttons and caption and must not get tiny. Divine snaps a poll back into its safe area after release, which feels odd on a phone, and clamping from MainEditorCallbacks.onScaleUpdate lands a frame late: the callback runs before the editor applies the step, so the step overwrites the clamp. Scale limits only existed per layer type (stickerEditor.minScale / maxScale for every WidgetLayer).

Change. Three optional fields on LayerInteraction, next to enableMove, enableScale and the other switches:

  • bounds (Rect Function(Size editorBodySize)?): the area the layer stays inside, in the body's logical pixels from its top left corner, like TextEditorConfigs.layerBounds. It is called on every gesture step, so it can follow whatever it depends on, such as the export's aspect ratio.
  • minScale / maxScale (double?): replace the limits of the layer's type for this layer, in either direction.
  • LayerInteractionManager.applyLayerLimits applies them to a layer changed by code, once it is laid out.

How the layer behaves with bounds:

  • Drag: it stops at the edge while the finger keeps going, without jitter, and follows again once the finger is back where it stopped. It keeps measuring from the finger rather than from the clamped position, with smart alignment on or off.
  • Pinch, rotate, scale button: it is pushed inwards and never grows past the area. Within the same gesture it moves back as it shrinks. The rotate/scale button turns around where the gesture found the layer, so the clamp does not feed back into the gesture.
  • Too large: a layer larger than the area is shrunk to fit, also with enableScale: false, but never below its minScale. One that still does not fit is centered on that side.
  • Measured as drawn: the box at its scale, turned by its rotation, without the selection frame. The size is measured when the gesture starts, before the history step copies the layers and before keyframes place them.
  • Helper lines: a helper line or alignment guide the bounds move the layer off is hidden, and it does not trigger a haptic hit on every step.
  • Desktop: the scroll and keyboard shortcuts keep the limits too.

Import/export. minScale and maxScale are exported only when set (minified n / x), so older states load unchanged. bounds is a function and is not exported; a host sets it again after an import. A history step whose only change is a different bounds function writes no interaction diff.

Fix found on the way. On import, a history step's interaction diff replaced the previous step's whole interaction map, so a step that changed one setting reset the others to their defaults. The diffs are now merged over the previous step. This also corrects older exports.

Not changed. Without the new fields nothing behaves differently: the drag path still adds each step to layer.offset, and the type-wide scale limits apply as before. LayerInteraction is still imported from core/models/layers/layer_interaction.dart and is not added to the barrel, because Divine imports that file directly and an extra export would flag it as an unnecessary import.

Limits. Text and emoji glyph sizes are rounded, so while one is pinched its edge can be a fraction of a pixel off. Widget layers scale exactly through their FittedBox. Each layer of a multi-selection stops at its own bounds.

CHANGELOG 14.23.0. The example app has a new Layers → Move and Scale Limits page.

Tests.

  • layer_interaction_test: defaults, toMap / fromMap (plain and minified), a map without the new keys, toMapFromReference with changed and removed limits, copyWith, equality, and Layer.toMapFromReference skipping a bounds-only change.
  • layer_interaction_history_test: a three-step history keeps every step's interaction through export and import (plain and minified), and a 6.0.0 history without the limits loads, with a one-key diff keeping the other settings.
  • layer_interaction_limits_test, in a running editor:
    • a drag stops at the edge and follows again (smart alignment on and off);
    • a layer without bounds moves as before;
    • a helper line the bounds move the layer off stays hidden;
    • a pinch at an edge stays inside and returns;
    • a rotation at an edge stays inside;
    • applyLayerLimits works on a layer moved by code;
    • per-layer limits replace the type's, the type's limits still apply without them, and bounds never shrink below minScale.
  • flutter analyze and dart format are clean, and all 1306 tests pass.
  • Tried in the example app on macOS with mouse dragging, scroll zoom and the rotation shortcuts.

Related Issue: Closes #none; needed for Divine's poll, quiz and slider stickers.

Type of Change

  • ✨ New feature (non-breaking change which adds functionality)
  • 🛠️ Bug fix (non-breaking change which fixes an issue)
  • ❌ Breaking change (fix or feature that would cause existing functionality to change)
  • 🧹 Code refactor
  • ✅ Build configuration change
  • 📝 Documentation
  • 🗑️ Chore

hm21 added 2 commits October 31, 2026 09:17
Add LayerInteraction.bounds, a function of the main editor's body size
that returns the area a layer stays inside, and LayerInteraction.minScale
and maxScale, which replace the scale limits of the layer's type for that
layer.

A dragged layer stops at the edge of its bounds and follows the finger
again once the finger is back where it stopped. Pinched, rotated or
scaled with the selection button at an edge, it is pushed inwards, does
not grow past the bounds and moves back as it shrinks within the same
gesture. A layer larger than its bounds is shrunk to fit, but not below
its minScale. The layer is measured as it is drawn: at its scale and
turned by its rotation. A helper line or guide the bounds move the layer
off is hidden. The desktop scroll and keyboard shortcuts keep the limits
too, and LayerInteractionManager.applyLayerLimits applies them to a layer
changed by code.

minScale and maxScale are exported with the layer (minified as n and x);
bounds, a function, is not. Importing a history now merges each step's
interaction settings over the previous step's instead of replacing them,
so a step that changes one setting no longer resets the others.
The line was placed from where the previous step left the layer and then
moved by the whole push of this step, so it drifted off a layer the
bounds pushed. It now moves by how far the layer moved since the line
was placed. Also note that resizing a text box ignores the bounds and
shorten the changelog entry.
@hm21
hm21 merged commit 30f8484 into stable Oct 31, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant