From 73c3827c593e7b4891bf5bb3c91ae295bf098a14 Mon Sep 17 00:00:00 2001 From: CodeDoctorDE Date: Fri, 21 Aug 2026 11:28:07 +0200 Subject: [PATCH] Add infinite zoom --- app/lib/cubits/transform.dart | 99 +++++++++++++++++++------ app/lib/handlers/presentation.dart | 8 +- app/lib/l10n/app_en.arb | 5 ++ app/lib/renderers/elements/text.dart | 6 +- app/lib/settings/home.dart | 1 + app/lib/settings/pages/experiments.dart | 10 +++ app/lib/views/main.dart | 6 +- app/lib/views/navigator/areas.dart | 1 + app/lib/views/navigator/waypoints.dart | 27 ++++++- app/test/cubits/transform_test.dart | 41 ++++++++++ 10 files changed, 176 insertions(+), 28 deletions(-) diff --git a/app/lib/cubits/transform.dart b/app/lib/cubits/transform.dart index 10c0d09fe9ef..48af78cc41e0 100644 --- a/app/lib/cubits/transform.dart +++ b/app/lib/cubits/transform.dart @@ -19,10 +19,14 @@ part 'transform.freezed.dart'; const kMinZoom = 0.1; const kMaxZoom = 10.0; +const kInfiniteZoomFlag = 'infiniteZoom'; const kRoundPrecision = 3; const kDrag = 0.001; const kBoundsSnapBackDuration = 0.18; +double? getZoomUpperBound(ButterflySettings settings) => + settings.hasFlag(kInfiniteZoomFlag) ? null : kMaxZoom; + @freezed sealed class FrictionState with _$FrictionState { const factory FrictionState( @@ -51,9 +55,16 @@ sealed class CameraTransform with _$CameraTransform { CameraTransform withPointPosition(Point position) => CameraTransform(pixelRatio, position.toOffset(), size, rotation); - CameraTransform withSize(double size, [Offset cursor = Offset.zero]) { + CameraTransform withSize( + double size, [ + Offset cursor = Offset.zero, + double? maxZoom = kMaxZoom, + ]) { // Set size and focus on cursor if provided - final double newSize = size.clamp(kMinZoom, kMaxZoom); + final requestedSize = size.isFinite ? size : this.size; + final double newSize = maxZoom == null + ? max(requestedSize, kMinZoom) + : requestedSize.clamp(kMinZoom, maxZoom); return CameraTransform( pixelRatio, localToGlobal(cursor) - cursor.rotate(Offset.zero, -rotation) / newSize, @@ -128,6 +139,7 @@ sealed class CameraTransform with _$CameraTransform { Offset velocityPosition, double velocitySize, { Rect? positionBounds, + double? maxZoom = kMaxZoom, }) { final simX = _getSimulation(velocityPosition.dx); final finalX = simX.finalX; @@ -141,7 +153,11 @@ sealed class CameraTransform with _$CameraTransform { final finalPosition = positionBounds == null ? position - finalPos : _clampPosition(position - finalPos, positionBounds); - final finalScale = (size + finalSize).clamp(kMinZoom, kMaxZoom); + final simulatedScale = size + finalSize; + final requestedScale = simulatedScale.isFinite ? simulatedScale : size; + final finalScale = maxZoom == null + ? max(requestedScale, kMinZoom) + : requestedScale.clamp(kMinZoom, maxZoom); var duration = max(durationPosition, durationSize); if (!duration.isFinite) { duration = 0; @@ -189,15 +205,27 @@ class TransformCubit extends Cubit { void move(Offset delta) => emit(state.withPosition(state.position + delta)); - void teleport(Offset position, [double? scale, double? rotation]) => emit( + void teleport( + Offset position, [ + double? scale, + double? rotation, + double? maxZoom = kMaxZoom, + ]) => emit( state .withPosition(position) - .withSize(scale ?? state.size) + .withSize( + scale ?? state.size, + Offset.zero, + scale == null ? null : maxZoom, + ) .withRotation(rotation ?? state.rotation), ); - void zoom(double delta, [Offset cursor = Offset.zero]) => - emit(state.withSize(state.size * delta, cursor)); + void zoom( + double delta, [ + Offset cursor = Offset.zero, + double? maxZoom = kMaxZoom, + ]) => emit(state.withSize(state.size * delta, cursor, maxZoom)); void focus(Offset cursor) => emit(state.withSize(state.size, cursor)); @@ -206,40 +234,54 @@ class TransformCubit extends Cubit { void reset() => emit(CameraTransform(state.pixelRatio)); - void size(double size, [Offset cursor = Offset.zero]) => - emit(state.withSize(size, cursor)); - - void teleportToWaypoint(Waypoint waypoint) => - teleport(waypoint.position.toOffset(), waypoint.scale ?? state.size); + void size( + double size, [ + Offset cursor = Offset.zero, + double? maxZoom = kMaxZoom, + ]) => emit(state.withSize(size, cursor, maxZoom)); + + void teleportToWaypoint(Waypoint waypoint, {double? maxZoom = kMaxZoom}) => + teleport( + waypoint.position.toOffset(), + waypoint.scale ?? state.size, + null, + maxZoom, + ); void teleportToArea( Area area, [ Size? screen, RenderResolution resolution = RenderResolution.performance, + double? maxZoom = kMaxZoom, ]) { if (screen == null || area.width <= 0 || area.height <= 0) { - teleport(area.position.toOffset(), state.size); + teleport(area.position.toOffset(), state.size, null, maxZoom); return; } final effectiveScreen = screen / resolution.multiplier; final width = effectiveScreen.width / area.width; final height = effectiveScreen.height / area.height; - final size = min(width, height).clamp(kMinZoom, kMaxZoom); + final requestedSize = min(width, height); + final size = maxZoom == null + ? max(requestedSize, kMinZoom) + : requestedSize.clamp(kMinZoom, maxZoom); final position = area.rect.center - effectiveScreen.center(Offset.zero) / size; - teleport(position, size); + teleport(position, size, null, maxZoom); } void slide( Offset velocityPosition, double velocitySize, { Rect? positionBounds, + double? maxZoom = kMaxZoom, }) => emit( state.withFriction( velocityPosition, velocitySize, positionBounds: positionBounds, + maxZoom: maxZoom, ), ); @@ -558,12 +600,16 @@ class TransformCubit extends Cubit { return; } if (force) { - zoom(delta, cursor); + zoom(delta, cursor, getZoomUpperBound(runtime.settingsCubit.state)); return; } - final transform = state.withSize(state.size * delta, cursor); + final transform = state.withSize( + state.size * delta, + cursor, + getZoomUpperBound(runtime.settingsCubit.state), + ); final clamped = _clampTransform(transform: transform, runtime: runtime); - teleport(clamped.position, clamped.size); + emit(clamped); } void rotateConstrained( @@ -587,14 +633,18 @@ class TransformCubit extends Cubit { final locks = runtime.viewCubit.state.locks; if (locks.lockZoom && !force) return; if (force) { - this.size(size, cursor); + this.size(size, cursor, getZoomUpperBound(runtime.settingsCubit.state)); return; } final transform = _clampTransform( - transform: state.withSize(size, cursor), + transform: state.withSize( + size, + cursor, + getZoomUpperBound(runtime.settingsCubit.state), + ), runtime: runtime, ); - teleport(transform.position, transform.size); + emit(transform); } void slideConstrained( @@ -655,6 +705,11 @@ class TransformCubit extends Cubit { !outOfBounds) { return; } - slide(positionVelocity, sizeVelocity, positionBounds: bounds); + slide( + positionVelocity, + sizeVelocity, + positionBounds: bounds, + maxZoom: getZoomUpperBound(runtime.settingsCubit.state), + ); } } diff --git a/app/lib/handlers/presentation.dart b/app/lib/handlers/presentation.dart index 03d39364d68e..b49b458367e8 100644 --- a/app/lib/handlers/presentation.dart +++ b/app/lib/handlers/presentation.dart @@ -139,7 +139,13 @@ mixin GeneralPresentationHandler { final zoom = animation.interpolateCameraZoom(currentFrame); if (position == null && zoom == null) return; if (position != null) transformCubit.teleport(position.toOffset()); - if (zoom != null) transformCubit.size(zoom); + if (zoom != null) { + transformCubit.size( + zoom, + Offset.zero, + getZoomUpperBound(cubit.settingsCubit.state), + ); + } bloc.delayedBake(testTransform: true); } diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 3e50bc8107b4..1d75a4b9ddda 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -1262,6 +1262,10 @@ "@edgePanAreaSwitching": { "description": "Switch areas when you pan near the edge of the canvas" }, + "infiniteZoom": "Infinite zoom", + "@infiniteZoom": { + "description": "Allow zooming beyond the standard maximum" + }, "useAndroidSaf": "Use Android SAF", "exact": "Exact", "@exact": { @@ -1553,6 +1557,7 @@ "onStartupNewNoteDescription": "Creates a new note when Butterfly starts.", "smoothNavigationDescription": "Makes panning and zooming feel smoother by simplifying the canvas while it is moving.", "edgePanAreaSwitchingDescription": "Switches to a neighboring area when you pan near the edge of the canvas.", + "infiniteZoomDescription": "Removes the standard maximum zoom level. Embedded raster content may stop getting sharper at very high zoom levels.", "collaborationDescription": "Allows multiple people to edit the same note together in real time.", "showVerboseLogsDescription": "Includes detailed diagnostic messages in the log view and console. These messages can help when troubleshooting a problem.", "bringMovedElementsToFrontDescription": "Places an element above overlapping elements after you move it.", diff --git a/app/lib/renderers/elements/text.dart b/app/lib/renderers/elements/text.dart index 4fcdc641cc16..647156275c0f 100644 --- a/app/lib/renderers/elements/text.dart +++ b/app/lib/renderers/elements/text.dart @@ -164,13 +164,15 @@ abstract class GenericTextRenderer extends Renderer { final span = paragraph.textSpans[i]; if (span is text.MathTextSpan && !_renderedLatex.containsKey(i)) { final widget = _buildLatexElement(styleSheet, paragraphStyle, span); + const maxRasterScale = 16.0; final pixelRatio = transform.pixelRatio * transform.size; + final renderPixelRatio = min(scale * pixelRatio, maxRasterScale); try { final image = await renderWidget( widget, - pixelRatio: scale * pixelRatio, + pixelRatio: renderPixelRatio, ); - _renderedLatex[i] = (image, pixelRatio); + _renderedLatex[i] = (image, renderPixelRatio / scale); } catch (_) {} } } diff --git a/app/lib/settings/home.dart b/app/lib/settings/home.dart index f014ae9a5375..3fd969f415e1 100644 --- a/app/lib/settings/home.dart +++ b/app/lib/settings/home.dart @@ -7,6 +7,7 @@ import 'package:butterfly/actions/shortcuts.dart'; import 'package:butterfly/api/file_system.dart'; import 'package:butterfly/api/open.dart'; import 'package:butterfly/cubits/settings.dart'; +import 'package:butterfly/cubits/transform.dart'; import 'package:butterfly/dialogs/input.dart'; import 'package:butterfly/main.dart'; import 'package:butterfly/repositories/document_state.dart'; diff --git a/app/lib/settings/pages/experiments.dart b/app/lib/settings/pages/experiments.dart index 7053385ce075..33fc3534c1d8 100644 --- a/app/lib/settings/pages/experiments.dart +++ b/app/lib/settings/pages/experiments.dart @@ -7,6 +7,7 @@ final _experimentsSettingsPage = SettingsLeapPage( AppLocalizations.of(context).collaboration, AppLocalizations.of(context).smoothNavigation, AppLocalizations.of(context).edgePanAreaSwitching, + AppLocalizations.of(context).infiniteZoom, ], appBarBuilder: _butterflyAppBar, onReset: (context, state) => context.read().resetFlags(), @@ -43,6 +44,15 @@ final _experimentsSettingsPage = SettingsLeapPage( write: (context, value) => _changeFlag(context, 'edgePanAreaSwitching', value), ), + SettingsLeapBoolSetting( + displayName: (context) => AppLocalizations.of(context).infiniteZoom, + hintBuilder: (context) => + AppLocalizations.of(context).infiniteZoomDescription, + icon: PhosphorIconsLight.infinity, + read: (state) => state.hasFlag(kInfiniteZoomFlag), + write: (context, value) => + _changeFlag(context, kInfiniteZoomFlag, value), + ), ], ), }, diff --git a/app/lib/views/main.dart b/app/lib/views/main.dart index 55d2eb49962b..730d6e1c418f 100644 --- a/app/lib/views/main.dart +++ b/app/lib/views/main.dart @@ -455,6 +455,7 @@ class _ProjectPageState extends State { ), initialSession.camera.zoom, initialSession.camera.rotation, + getZoomUpperBound(settingsCubit.state), ); final editorSessionCubit = EditorSessionCubit( repository: documentStateRepository, @@ -488,7 +489,10 @@ class _ProjectPageState extends State { return; } if (restoredSession == null) { - transformCubit.teleportToWaypoint(page.getOriginWaypoint()); + transformCubit.teleportToWaypoint( + page.getOriginWaypoint(), + maxZoom: getZoomUpperBound(settingsCubit.state), + ); } final editorController = EditorController( settingsCubit, diff --git a/app/lib/views/navigator/areas.dart b/app/lib/views/navigator/areas.dart index b48c5ea9a66e..35e5dd00037c 100644 --- a/app/lib/views/navigator/areas.dart +++ b/app/lib/views/navigator/areas.dart @@ -128,6 +128,7 @@ class _AreasViewState extends State { area, viewport.toSize(), viewport.resolution, + getZoomUpperBound(context.read().state), ); bloc.add(CurrentAreaChanged(area.name)); } diff --git a/app/lib/views/navigator/waypoints.dart b/app/lib/views/navigator/waypoints.dart index 9e3de9af20cb..e2fadbfc5b22 100644 --- a/app/lib/views/navigator/waypoints.dart +++ b/app/lib/views/navigator/waypoints.dart @@ -1,4 +1,5 @@ import 'package:butterfly/bloc/document_bloc.dart'; +import 'package:butterfly/cubits/settings.dart'; import 'package:butterfly_api/butterfly_api.dart'; import 'package:flutter/material.dart'; import 'package:butterfly/src/generated/i18n/app_localizations.dart'; @@ -85,7 +86,14 @@ class _WaypointsViewState extends State { onTap: () { context .read() - .teleportToWaypoint(origin); + .teleportToWaypoint( + origin, + maxZoom: getZoomUpperBound( + context + .read() + .state, + ), + ); context.read().bake(); }, trailing: button, @@ -159,6 +167,14 @@ class _WaypointsViewState extends State { bloc.transformCubit .teleportToWaypoint( Waypoint.defaultOrigin, + maxZoom: + getZoomUpperBound( + context + .read< + SettingsCubit + >() + .state, + ), ); bloc.delayedBake(); }, @@ -218,7 +234,14 @@ class _WaypointsViewState extends State { onTap: () { context .read() - .teleportToWaypoint(waypoint); + .teleportToWaypoint( + waypoint, + maxZoom: getZoomUpperBound( + context + .read() + .state, + ), + ); context.read().bake(); }, onSaved: (value) => diff --git a/app/test/cubits/transform_test.dart b/app/test/cubits/transform_test.dart index aaf7fa5ac88f..7898a56d797d 100644 --- a/app/test/cubits/transform_test.dart +++ b/app/test/cubits/transform_test.dart @@ -1,5 +1,6 @@ import 'dart:math'; +import 'package:butterfly/cubits/settings.dart'; import 'package:butterfly/cubits/transform.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -61,4 +62,44 @@ void main() { ); }); }); + + group('CameraTransform zoom limits', () { + test('uses an unbounded upper limit only for the experiment flag', () { + expect(getZoomUpperBound(const ButterflySettings()), kMaxZoom); + expect( + getZoomUpperBound(const ButterflySettings(flags: [kInfiniteZoomFlag])), + isNull, + ); + }); + + test('uses the standard upper zoom limit by default', () { + const transform = CameraTransform(); + + expect(transform.withSize(100).size, kMaxZoom); + }); + + test( + 'allows finite zoom levels above the standard limit when unbounded', + () { + const transform = CameraTransform(); + + expect(transform.withSize(1000, Offset.zero, null).size, 1000); + }, + ); + + test('ignores non-finite zoom levels', () { + const transform = CameraTransform(1, Offset.zero, 25); + + expect(transform.withSize(double.infinity, Offset.zero, null).size, 25); + }); + + test('position-only teleports preserve a high zoom level', () { + final cubit = TransformCubit(1)..size(1000, Offset.zero, null); + + cubit.teleport(const Offset(20, 30)); + + expect(cubit.state.position, const Offset(20, 30)); + expect(cubit.state.size, 1000); + }); + }); }