From b8de8e27adae338e25a8b2d5743e7f25fd39f51e Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 18 Oct 2026 16:05:45 +0300 Subject: [PATCH 1/2] refactor(erd): ErdViewport holds zoom and pan, ErdCanvas puts the layers in order (#1690) --- lib/features/erd/erd_view.dart | 258 +++++------------------ lib/features/erd/erd_viewport.dart | 109 ++++++++++ lib/features/erd/widgets/erd_canvas.dart | 188 +++++++++++++++++ 3 files changed, 350 insertions(+), 205 deletions(-) create mode 100644 lib/features/erd/erd_viewport.dart create mode 100644 lib/features/erd/widgets/erd_canvas.dart diff --git a/lib/features/erd/erd_view.dart b/lib/features/erd/erd_view.dart index 25562d69..22ebebda 100644 --- a/lib/features/erd/erd_view.dart +++ b/lib/features/erd/erd_view.dart @@ -1,5 +1,5 @@ import 'dart:async'; -import 'dart:math' show max, min; +import 'dart:math' show max; import 'dart:typed_data'; import 'package:file_selector/file_selector.dart'; @@ -8,7 +8,6 @@ import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart' show HardwareKeyboard, LogicalKeyboardKey; import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; -import 'package:querya_desktop/features/erd/erd_canvas_controls.dart'; import 'package:querya_desktop/features/erd/erd_card_measure.dart'; import 'package:querya_desktop/core/erd/erd_catalog.dart'; import 'package:querya_desktop/features/erd/erd_source.dart'; @@ -19,6 +18,7 @@ import 'package:querya_desktop/core/erd/erd_geometry.dart'; import 'package:querya_desktop/features/erd/erd_group_dialog.dart'; import 'package:querya_desktop/features/erd/erd_note_dialog.dart'; import 'package:querya_desktop/features/erd/erd_view_controller.dart'; +import 'package:querya_desktop/features/erd/erd_viewport.dart'; import 'package:querya_desktop/features/erd/erd_view_name_dialog.dart'; import 'package:querya_desktop/core/erd/erd_group_ops.dart'; import 'package:querya_desktop/core/erd/erd_layout.dart'; @@ -27,13 +27,13 @@ import 'package:querya_desktop/core/erd/erd_model.dart'; import 'package:querya_desktop/core/erd/erd_note_ops.dart'; import 'package:querya_desktop/core/erd/erd_saved_layout.dart'; import 'package:querya_desktop/core/erd/erd_router.dart'; +import 'package:querya_desktop/features/erd/widgets/erd_canvas.dart'; import 'package:querya_desktop/features/erd/widgets/erd_card_cache.dart'; import 'package:querya_desktop/features/erd/widgets/erd_card_inputs.dart'; import 'package:querya_desktop/features/erd/widgets/erd_edge_tip_label.dart'; import 'package:querya_desktop/features/erd/widgets/erd_export_menu.dart'; import 'package:querya_desktop/features/erd/widgets/erd_group_frame.dart'; import 'package:querya_desktop/features/erd/widgets/erd_note_card.dart'; -import 'package:querya_desktop/features/erd/widgets/erd_relation_painter.dart'; import 'package:querya_desktop/features/erd/widgets/erd_search_panel.dart'; import 'package:querya_desktop/features/erd/widgets/erd_status_body.dart'; import 'package:querya_desktop/features/erd/widgets/erd_group_menu.dart'; @@ -171,17 +171,7 @@ class _ErdViewState extends material.State final _searchController = material.TextEditingController(); final _searchFocus = material.FocusNode(); final _canvasFocus = material.FocusNode(); - final _viewportKey = material.GlobalKey(); - - final _transform = material.TransformationController(); - - static const double _minScale = 0.2; - static const double _maxScale = 3; - static const double _zoomStep = 1.25; - - /// Lowest zoom: [_minScale], or lower when the whole diagram needs it to - /// fit the viewport (a schema of a few hundred tables). - double _zoomFloor = _minScale; + final _viewport = ErdViewport(); /// Saves of the layout wait for a pause in the changes. Timer? _saveTimer; @@ -198,8 +188,8 @@ class _ErdViewState extends material.State _saveTimer!.cancel(); unawaited(_saveNow()); } - _transform.removeListener(_scheduleSave); - _transform.dispose(); + _viewport.transform.removeListener(_scheduleSave); + _viewport.dispose(); _hovered.dispose(); _focus.dispose(); for (final card in _cardStates.values) { @@ -217,7 +207,7 @@ class _ErdViewState extends material.State super.initState(); _searchFocus.addListener(_onSearchFocus); _hovered.addListener(_syncFocus); - _transform.addListener(_scheduleSave); + _viewport.transform.addListener(_scheduleSave); _load(); } @@ -293,9 +283,7 @@ class _ErdViewState extends material.State final translation = saved?.translation; if (scale != null && translation != null) { _restoring = true; - _transform.value = Matrix4.identity() - ..translate(translation.dx, translation.dy) - ..scale(scale); + _viewport.set(scale: scale, translation: translation); _restoring = false; } else { _fit(); @@ -427,9 +415,7 @@ class _ErdViewState extends material.State final scale = target.scale, translation = target.translation; if (scale != null && translation != null) { _restoring = true; - _transform.value = Matrix4.identity() - ..translate(translation.dx, translation.dy) - ..scale(scale); + _viewport.set(scale: scale, translation: translation); _restoring = false; } else { _fit(); @@ -503,22 +489,12 @@ class _ErdViewState extends material.State _showState(null, _c.base); } - /// Canvas point at the middle of what the viewport shows. - material.Offset _viewCentre() { - final vs = _viewportSize(); - if (vs == null) return const material.Offset(60, 60); - final inverse = Matrix4.inverted(_transform.value); - final p = material.MatrixUtils.transformPoint( - inverse, vs.center(material.Offset.zero)); - return p; - } - Future _addNote() async { final text = await showErdNoteDialog(context, title: 'New note', action: 'Add'); if (text == null || !mounted) return; final note = - ErdNoteOps.create(_c.notes, text: text, centre: _viewCentre()); + ErdNoteOps.create(_c.notes, text: text, centre: _viewport.centre()); setState(() => _c.addNote(note)); _scheduleSave(); } @@ -542,11 +518,11 @@ class _ErdViewState extends material.State } void _noteDragMove(String id, material.Offset screenDelta) { - setState(() => _c.moveNote(id, screenDelta, _scale)); + setState(() => _c.moveNote(id, screenDelta, _viewport.scale)); } void _noteResize(String id, material.Offset screenDelta) { - setState(() => _c.resizeNote(id, screenDelta, _scale)); + setState(() => _c.resizeNote(id, screenDelta, _viewport.scale)); } /// The table nearest to a note's middle, to attach it to. @@ -596,7 +572,7 @@ class _ErdViewState extends material.State if (_c.draggingGroup != group.id || layout == null) return; final frame = layout.frameOf(group.tables); if (frame == null) return; - final scale = _scale; + final scale = _viewport.scale; var delta = screenDelta / (scale == 0 ? 1 : scale); delta = material.Offset( max(delta.dx, -frame.left), max(delta.dy, -frame.top)); @@ -639,8 +615,6 @@ class _ErdViewState extends material.State /// What the diagram shows and how it is arranged right now. ErdSavedLayout _current() { - final m = _transform.value; - final t = m.getTranslation(); return ErdSavedLayout( positions: Map.of(_layout?.positions ?? const {}), collapsed: Set.of(_c.collapsed), @@ -649,8 +623,8 @@ class _ErdViewState extends material.State headerColors: Map.of(_c.headerColors), groups: List.of(_c.groups), notes: List.of(_c.notes), - scale: m.getMaxScaleOnAxis(), - translation: material.Offset(t.x, t.y), + scale: _viewport.scale, + translation: _viewport.translation, ); } @@ -755,7 +729,7 @@ class _ErdViewState extends material.State void _dragMove(String table, material.Offset screenDelta) { final layout = _layout; if (_c.dragging != table || layout == null) return; - final scale = _transform.value.getMaxScaleOnAxis(); + final scale = _viewport.scale; final delta = screenDelta / (scale == 0 ? 1 : scale); // The card follows the pointer at once; the edges are routed once per frame. setState(() { @@ -793,42 +767,15 @@ class _ErdViewState extends material.State /// The table whose relations are highlighted: the one being dragged, then /// the picked one, then the one under the mouse. - double get _scale => _transform.value.getMaxScaleOnAxis(); - - /// Size of the diagram viewport, once it has been laid out. - material.Size? _viewportSize() { - final box = _viewportKey.currentContext?.findRenderObject() as RenderBox?; - return box != null && box.hasSize ? box.size : null; - } - - /// Sets the zoom to [target], keeping the scene point under [focal] still. - void _setScale(double target, material.Offset focal) { - final s = target.clamp(_zoomFloor, _maxScale).toDouble(); - final t = _transform.value.getTranslation(); - final current = material.Offset(t.x, t.y); - final next = focal - (focal - current) * (s / _scale); - _transform.value = Matrix4.identity()..translate(next.dx, next.dy)..scale(s); - } - - void _zoomBy(double factor) { - final vs = _viewportSize(); - if (vs == null) return; - _setScale(_scale * factor, vs.center(material.Offset.zero)); - } + void _zoomBy(double factor) => _viewport.zoomBy(factor); /// Fits the whole diagram into the viewport, centred, never enlarged. void _fit() { final layout = _layout; - final vs = _viewportSize(); - if (layout == null || vs == null) return; + if (layout == null) return; // The cards and every note: a note placed away from the cards counts. - final size = _c.canvasSize(layout); - final s = ErdLayout.fitScale(size, vs); - final floor = min(_minScale, s); - if (floor != _zoomFloor) setState(() => _zoomFloor = floor); - final tx = (vs.width - size.width * s) / 2; - final ty = (vs.height - size.height * s) / 2; - _transform.value = Matrix4.identity()..translate(tx, ty)..scale(s); + final floorMoved = _viewport.fit(_c.canvasSize(layout)); + if (floorMoved ?? false) setState(() {}); } /// Picks a table from the search: centres it, closes the search and gives @@ -846,15 +793,11 @@ class _ErdViewState extends material.State /// Picks a table and centres the view on it, keeping the zoom. void _selectTable(ErdSchema schema, String name) { final layout = _layout; - final vs = _viewportSize(); final table = schema.tables.firstWhere((t) => t.name == name); - if (layout != null && vs != null) { + if (layout != null) { final p = layout.positions[name]!; - final centre = material.Offset(p.dx + layout.widthFor(name) / 2, - p.dy + ErdLayout.cardHeight(table) / 2); - final s = _scale; - final t = vs.center(material.Offset.zero) - centre * s; - _transform.value = Matrix4.identity()..translate(t.dx, t.dy)..scale(s); + _viewport.centreOn(material.Offset(p.dx + layout.widthFor(name) / 2, + p.dy + ErdLayout.cardHeight(table) / 2)); } setState(() => _c.selected = name); _syncFocus(); @@ -933,13 +876,13 @@ class _ErdViewState extends material.State out[material.SingleActivator(key, meta: true, shift: shift)] = action; } - both(LogicalKeyboardKey.equal, () => _zoomBy(_zoomStep)); + both(LogicalKeyboardKey.equal, () => _zoomBy(ErdViewport.zoomStep)); // "+" is Shift+= on most layouts. - both(LogicalKeyboardKey.equal, () => _zoomBy(_zoomStep), shift: true); - both(LogicalKeyboardKey.add, () => _zoomBy(_zoomStep)); - both(LogicalKeyboardKey.numpadAdd, () => _zoomBy(_zoomStep)); - both(LogicalKeyboardKey.minus, () => _zoomBy(1 / _zoomStep)); - both(LogicalKeyboardKey.numpadSubtract, () => _zoomBy(1 / _zoomStep)); + both(LogicalKeyboardKey.equal, () => _zoomBy(ErdViewport.zoomStep), shift: true); + both(LogicalKeyboardKey.add, () => _zoomBy(ErdViewport.zoomStep)); + both(LogicalKeyboardKey.numpadAdd, () => _zoomBy(ErdViewport.zoomStep)); + both(LogicalKeyboardKey.minus, () => _zoomBy(1 / ErdViewport.zoomStep)); + both(LogicalKeyboardKey.numpadSubtract, () => _zoomBy(1 / ErdViewport.zoomStep)); both(LogicalKeyboardKey.digit0, _fit); both(LogicalKeyboardKey.keyF, _openSearch); out[const material.SingleActivator(LogicalKeyboardKey.escape)] = _escape; @@ -1166,7 +1109,7 @@ class _ErdViewState extends material.State /// The relation within 6 screen px of [p] (canvas space), whatever the zoom. ErdRelation? _relationAt(material.Offset p) { ErdRelation? hit; - final scale = _scale; + final scale = _viewport.scale; var best = 6.0 / (scale <= 0 ? 1 : scale); for (final r in _routes) { final d = ErdGeometry.distanceToRoute(r.points, p); @@ -1183,10 +1126,6 @@ class _ErdViewState extends material.State _edgeTipNotifier.value = null; } - /// "orders.customer_id → customers.id" next to the pointer. - material.Widget _edgeTipLabel(material.BuildContext context, ErdRelation r) => - ErdEdgeTipLabel(relation: r); - /// Find-a-table field and the tables it matches. Enter picks the first. material.Widget _searchPanel(ErdSchema schema) { if (!_c.searchOpen) return const material.SizedBox.shrink(); @@ -1304,7 +1243,6 @@ class _ErdViewState extends material.State @override material.Widget build(material.BuildContext context) { - final wb = context.workbench; final full = _schema; final schema = full == null ? null : _visibleOf(full); final layout = _layout; @@ -1329,120 +1267,30 @@ class _ErdViewState extends material.State focusTable: widget.focusTable, onOpenFullDiagram: widget.onOpenFullDiagram); } else { - body = material.CallbackShortcuts( + body = ErdCanvas( + viewport: _viewport, + boundaryKey: _boundaryKey, + canvasFocus: _canvasFocus, bindings: _bindings(), - child: material.Focus( - focusNode: _canvasFocus, - autofocus: true, - child: material.Stack( - key: _viewportKey, - children: [ - material.InteractiveViewer( - constrained: false, - transformationController: _transform, - // A card or group drag must not pan the canvas. - panEnabled: _c.dragging == null && _c.draggingGroup == null, - minScale: _zoomFloor, - maxScale: _maxScale, - boundaryMargin: const material.EdgeInsets.all(400), - child: material.RepaintBoundary( - key: _boundaryKey, - child: material.Container( - color: wb.surface, - width: _c.canvasSize(layout).width, - height: _c.canvasSize(layout).height, - child: material.GestureDetector( - // A tap on an edge picks its relation, elsewhere it clears - // the selection (#1281). - behavior: material.HitTestBehavior.translucent, - onTapUp: (d) => _tapCanvas(d.localPosition), - child: material.Stack( - children: [ - // Lowest layer: sees the pointer wherever no card is, - // so edges can name themselves on hover. - material.Positioned.fill( - child: material.MouseRegion( - opaque: false, - onHover: (e) => _hoverCanvas(e.localPosition), - onExit: (_) => _leaveCanvas(), - child: const material.SizedBox.expand(), - ), - ), - // Group frames under the edges and cards (#1282). - for (final g in _c.allGroups(_schema)) - ..._groupFrame(g, layout), - material.Positioned.fill( - child: material.ValueListenableBuilder( - valueListenable: _focus, - builder: (context, focus, _) => - material.CustomPaint( - painter: ErdRelationPainter( - routes: _routes, - color: wb.mutedForeground, - highlight: wb.accent, - focus: focus, - picked: _c.pickedRelation, - ), - ), - ), - ), - ..._cards(schema, layout), - // Sticky notes above the cards (#1283). - for (final n in _c.notes) _noteWidget(n), - ], - ), - ), - ), - ), - ), - // The edge label sits above the zoomed canvas, in screen space, - // so it stays readable at any zoom and is never in a PNG. - material.Positioned.fill( - child: material.IgnorePointer( - child: material.ListenableBuilder( - listenable: material.Listenable.merge( - [_edgeTipNotifier, _transform]), - builder: (context, _) { - final tip = _edgeTipNotifier.value; - if (tip == null) return const material.SizedBox.shrink(); - final at = material.MatrixUtils.transformPoint( - _transform.value, tip.$2); - return material.Stack( - children: [ - material.Positioned( - key: const material.ValueKey('erd_edge_tip'), - left: at.dx + 12, - top: at.dy + 12, - child: _edgeTipLabel(context, tip.$1), - ), - ], - ); - }, - ), - ), - ), - material.Positioned( - left: 8, - top: 8, - width: 260, - child: _searchPanel(schema), - ), - material.Positioned( - right: 8, - bottom: 8, - child: material.ValueListenableBuilder( - valueListenable: _transform, - builder: (context, m, _) => ErdZoomControls( - scale: m.getMaxScaleOnAxis(), - onZoomIn: () => _zoomBy(_zoomStep), - onZoomOut: () => _zoomBy(1 / _zoomStep), - onFit: _fit, - ), - ), - ), - ], - ), - ), + canvasSize: _c.canvasSize(layout), + panEnabled: _c.dragging == null && _c.draggingGroup == null, + routes: _routes, + focus: _focus, + pickedRelation: _c.pickedRelation, + edgeTip: _edgeTipNotifier, + edgeTipLabel: (relation) => ErdEdgeTipLabel(relation: relation), + onTapUp: _tapCanvas, + onHover: _hoverCanvas, + onLeave: _leaveCanvas, + groupFrames: [ + for (final g in _c.allGroups(_schema)) ..._groupFrame(g, layout), + ], + cards: _cards(schema, layout), + notes: [for (final n in _c.notes) _noteWidget(n)], + searchPanel: _searchPanel(schema), + onZoomIn: () => _zoomBy(ErdViewport.zoomStep), + onZoomOut: () => _zoomBy(1 / ErdViewport.zoomStep), + onFit: _fit, ); } final ready = schema != null && layout != null && !schema.isEmpty; diff --git a/lib/features/erd/erd_viewport.dart b/lib/features/erd/erd_viewport.dart new file mode 100644 index 00000000..00f3eeea --- /dev/null +++ b/lib/features/erd/erd_viewport.dart @@ -0,0 +1,109 @@ +import 'dart:math' show min; + +import 'package:flutter/material.dart'; +import 'package:querya_desktop/core/erd/erd_layout.dart'; + +/// Where the canvas is: zoom and pan of the diagram in its viewport (#1690). +/// +/// Wraps the [TransformationController] the `InteractiveViewer` drives and +/// the maths on it (zoom about a point, fit, centre on a point), so the view +/// asks for what it wants and not for matrices. +class ErdViewport { + ErdViewport(); + + static const double minScale = 0.2; + static const double maxScale = 3; + static const double zoomStep = 1.25; + + final transform = TransformationController(); + + /// Marks the viewport box, to read its size once it has been laid out. + final key = GlobalKey(); + + /// Lowest zoom: [minScale], or lower when the whole diagram needs it to fit + /// the viewport (a schema of a few hundred tables). + double zoomFloor = minScale; + + double get scale => transform.value.getMaxScaleOnAxis(); + + /// Where the canvas origin sits in the viewport. + Offset get translation { + final t = transform.value.getTranslation(); + return Offset(t.x, t.y); + } + + /// Size of the viewport, once it has been laid out. + Size? get size { + final box = key.currentContext?.findRenderObject() as RenderBox?; + return box != null && box.hasSize ? box.size : null; + } + + void dispose() => transform.dispose(); + + /// Zoom [scale], with the canvas origin at [translation]. + void set({required double scale, required Offset translation}) { + transform.value = Matrix4.identity() + ..translate(translation.dx, translation.dy) + ..scale(scale); + } + + /// Sets the zoom to [target] (within the floor and [maxScale]), keeping the + /// scene point under [focal] still. + void zoomTo(double target, Offset focal) { + final s = target.clamp(zoomFloor, maxScale).toDouble(); + final next = focal - (focal - translation) * (s / scale); + set(scale: s, translation: next); + } + + /// Zooms by [factor] about the middle of the viewport. + void zoomBy(double factor) { + final vs = size; + if (vs == null) return; + zoomTo(scale * factor, vs.center(Offset.zero)); + } + + /// Fits [content] into the viewport, centred, never enlarged. Returns + /// whether [zoomFloor] changed (the viewer's lower limit moves with it), or + /// null when the viewport is not laid out yet. + bool? fit(Size content) { + final vs = size; + if (vs == null) return null; + final s = ErdLayout.fitScale(content, vs); + final floor = min(minScale, s); + final changed = floor != zoomFloor; + zoomFloor = floor; + set( + scale: s, + translation: Offset( + (vs.width - content.width * s) / 2, + (vs.height - content.height * s) / 2, + ), + ); + return changed; + } + + /// Centres the canvas point [point] in the viewport, keeping the zoom. + void centreOn(Offset point) { + final vs = size; + if (vs == null) return; + final s = scale; + set(scale: s, translation: vs.center(Offset.zero) - point * s); + } + + /// Canvas point at the middle of what the viewport shows, or [fallback] + /// before the viewport is laid out. + Offset centre({Offset fallback = const Offset(60, 60)}) { + final vs = size; + if (vs == null) return fallback; + return MatrixUtils.transformPoint( + Matrix4.inverted(transform.value), vs.center(Offset.zero)); + } + + /// A screen position as a canvas position. + Offset toCanvas(Offset screen) => + MatrixUtils.transformPoint(Matrix4.inverted(transform.value), screen); + + /// A canvas position as a screen position. + Offset toScreen(Offset canvas) => + MatrixUtils.transformPoint(transform.value, canvas); +} diff --git a/lib/features/erd/widgets/erd_canvas.dart b/lib/features/erd/widgets/erd_canvas.dart new file mode 100644 index 00000000..0f05f2e8 --- /dev/null +++ b/lib/features/erd/widgets/erd_canvas.dart @@ -0,0 +1,188 @@ +import 'package:flutter/foundation.dart' show ValueListenable; +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/erd/erd_model.dart'; +import 'package:querya_desktop/core/erd/erd_router.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:querya_desktop/features/erd/erd_canvas_controls.dart'; +import 'package:querya_desktop/features/erd/erd_viewport.dart'; +import 'package:querya_desktop/features/erd/widgets/erd_relation_painter.dart'; + +/// The diagram's canvas: a zoomable, pannable sheet with the group frames, +/// the edges, the cards and the notes on it, and over it, in screen space, the +/// edge label, the search panel and the zoom controls (#1690). +/// +/// The layers are built by the view and handed in; this puts them in order and +/// wires the viewport, the pointer and the keyboard to them. +class ErdCanvas extends material.StatelessWidget { + const ErdCanvas({ + super.key, + required this.viewport, + required this.boundaryKey, + required this.canvasFocus, + required this.bindings, + required this.canvasSize, + required this.panEnabled, + required this.routes, + required this.focus, + required this.pickedRelation, + required this.edgeTip, + required this.edgeTipLabel, + required this.onTapUp, + required this.onHover, + required this.onLeave, + required this.groupFrames, + required this.cards, + required this.notes, + required this.searchPanel, + required this.onZoomIn, + required this.onZoomOut, + required this.onFit, + }); + + final ErdViewport viewport; + + /// The `RepaintBoundary` the PNG export captures. + final material.GlobalKey boundaryKey; + final material.FocusNode canvasFocus; + final Map bindings; + final material.Size canvasSize; + + /// False while a card or a group is dragged: that drag must not pan. + final bool panEnabled; + final List routes; + + /// The table whose edges are highlighted. + final ValueListenable focus; + final ErdRelation? pickedRelation; + + /// The relation under the pointer and the pointer, in canvas space. + final ValueListenable<(ErdRelation, material.Offset)?> edgeTip; + final material.Widget Function(ErdRelation relation) edgeTipLabel; + final void Function(material.Offset canvasPoint) onTapUp; + final void Function(material.Offset canvasPoint) onHover; + final material.VoidCallback onLeave; + final List groupFrames; + final List cards; + final List notes; + final material.Widget searchPanel; + final material.VoidCallback onZoomIn; + final material.VoidCallback onZoomOut; + final material.VoidCallback onFit; + + @override + material.Widget build(material.BuildContext context) { + final wb = context.workbench; + return material.CallbackShortcuts( + bindings: bindings, + child: material.Focus( + focusNode: canvasFocus, + autofocus: true, + child: material.Stack( + key: viewport.key, + children: [ + material.InteractiveViewer( + constrained: false, + transformationController: viewport.transform, + panEnabled: panEnabled, + minScale: viewport.zoomFloor, + maxScale: ErdViewport.maxScale, + boundaryMargin: const material.EdgeInsets.all(400), + child: material.RepaintBoundary( + key: boundaryKey, + child: material.Container( + color: wb.surface, + width: canvasSize.width, + height: canvasSize.height, + child: material.GestureDetector( + // A tap on an edge picks its relation, elsewhere it clears + // the selection (#1281). + behavior: material.HitTestBehavior.translucent, + onTapUp: (d) => onTapUp(d.localPosition), + child: material.Stack( + children: [ + // Lowest layer: sees the pointer wherever no card is, + // so edges can name themselves on hover. + material.Positioned.fill( + child: material.MouseRegion( + opaque: false, + onHover: (e) => onHover(e.localPosition), + onExit: (_) => onLeave(), + child: const material.SizedBox.expand(), + ), + ), + // Group frames under the edges and cards (#1282). + ...groupFrames, + material.Positioned.fill( + child: material.ValueListenableBuilder( + valueListenable: focus, + builder: (context, focus, _) => + material.CustomPaint( + painter: ErdRelationPainter( + routes: routes, + color: wb.mutedForeground, + highlight: wb.accent, + focus: focus, + picked: pickedRelation, + ), + ), + ), + ), + ...cards, + // Sticky notes above the cards (#1283). + ...notes, + ], + ), + ), + ), + ), + ), + // The edge label sits above the zoomed canvas, in screen space, so + // it stays readable at any zoom and is never in a PNG. + material.Positioned.fill( + child: material.IgnorePointer( + child: material.ListenableBuilder( + listenable: + material.Listenable.merge([edgeTip, viewport.transform]), + builder: (context, _) { + final tip = edgeTip.value; + if (tip == null) return const material.SizedBox.shrink(); + final at = viewport.toScreen(tip.$2); + return material.Stack( + children: [ + material.Positioned( + key: const material.ValueKey('erd_edge_tip'), + left: at.dx + 12, + top: at.dy + 12, + child: edgeTipLabel(tip.$1), + ), + ], + ); + }, + ), + ), + ), + material.Positioned( + left: 8, + top: 8, + width: 260, + child: searchPanel, + ), + material.Positioned( + right: 8, + bottom: 8, + child: material.ValueListenableBuilder( + valueListenable: viewport.transform, + builder: (context, m, _) => ErdZoomControls( + scale: m.getMaxScaleOnAxis(), + onZoomIn: onZoomIn, + onZoomOut: onZoomOut, + onFit: onFit, + ), + ), + ), + ], + ), + ), + ); + } +} From 7df8aac5cf038d1dddcca01739fcf272d5506aa6 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 18 Oct 2026 16:10:12 +0300 Subject: [PATCH 2/2] refactor(erd): saving and view switching rules move to the controller; viewport and controller tests (#1690) --- lib/features/erd/erd_view.dart | 75 +++----------- lib/features/erd/erd_view_controller.dart | 77 +++++++++++++++ .../erd/erd_view_controller_test.dart | 85 ++++++++++++++++ test/features/erd/erd_viewport_test.dart | 99 +++++++++++++++++++ 4 files changed, 276 insertions(+), 60 deletions(-) create mode 100644 test/features/erd/erd_viewport_test.dart diff --git a/lib/features/erd/erd_view.dart b/lib/features/erd/erd_view.dart index 22ebebda..adc5e310 100644 --- a/lib/features/erd/erd_view.dart +++ b/lib/features/erd/erd_view.dart @@ -429,15 +429,9 @@ class _ErdViewState extends material.State final schema = _schema; if (schema == null || id == _c.activeView) return; _captureCurrent(); - if (id == null) { - _showState(null, _c.base); - return; - } - final view = _c.viewById(id); - if (view == null) return; - _showState( - id, - view.asLayout({for (final t in schema.tables) t.name}, _c.headerColors)); + final target = + _c.stateOfView(id, {for (final t in schema.tables) t.name}); + if (target != null) _showState(id, target); } /// Saves what the diagram shows as a new view and moves into it. @@ -449,24 +443,12 @@ class _ErdViewState extends material.State action: 'Save', name: 'View ${_c.views.length + 1}'); if (name == null || !mounted) return; - final fromAll = _c.viewById(_c.activeView) == null; _captureCurrent(); - // The tables hidden by hand now belong to the view; All tables shows - // every table, always. - if (fromAll) _c.base = _c.base.showingAll(); - final id = _c.nextViewId(); - setState(() { - _c.views.add(ErdSavedView( - id: id, - name: name, - tables: { - for (final t in schema.tables) - if (!_c.hidden.contains(t.name)) t.name, - }, - layout: _current(), - )); - _c.activeView = id; - }); + setState(() => _c.addViewFromCurrent( + name: name, + current: _current(), + tableNames: [for (final t in schema.tables) t.name], + )); _scheduleSave(); } @@ -614,40 +596,18 @@ class _ErdViewState extends material.State } /// What the diagram shows and how it is arranged right now. - ErdSavedLayout _current() { - return ErdSavedLayout( - positions: Map.of(_layout?.positions ?? const {}), - collapsed: Set.of(_c.collapsed), - hidden: Set.of(_c.hidden), - detail: _c.detail, - headerColors: Map.of(_c.headerColors), - groups: List.of(_c.groups), - notes: List.of(_c.notes), - scale: _viewport.scale, - translation: _viewport.translation, - ); - } + ErdSavedLayout _current() => _c.snapshot( + positions: _layout?.positions ?? const {}, + scale: _viewport.scale, + translation: _viewport.translation, + ); /// Writes the current state to where it belongs: the active view, or the /// diagram's own (*All tables*) state. void _captureCurrent() { final schema = _schema; if (schema == null || _layout == null) return; - final cur = _current(); - final i = _c.views.indexWhere((v) => v.id == _c.activeView); - if (i < 0) { - _c.base = cur; - return; - } - _c.views[i] = ErdSavedView( - id: _c.views[i].id, - name: _c.views[i].name, - tables: { - for (final t in schema.tables) - if (!_c.hidden.contains(t.name)) t.name, - }, - layout: cur, - ); + _c.capture(_current(), [for (final t in schema.tables) t.name]); } Future _saveNow() async { @@ -655,12 +615,7 @@ class _ErdViewState extends material.State if (!_persists || layout == null) return; _captureCurrent(); try { - await widget.layoutStore!.write( - widget.layoutKey!, - // The diagram's own state, with the header colours as they are now. - _c.base.withViews(List.of(_c.views), _c.activeView, - headerColors: Map.of(_c.headerColors)), - ); + await widget.layoutStore!.write(widget.layoutKey!, _c.toStore()); } catch (_) { // Keeping the layout is a convenience: a failed write is not an error // the user can act on. diff --git a/lib/features/erd/erd_view_controller.dart b/lib/features/erd/erd_view_controller.dart index 30e74b3d..2410fcc9 100644 --- a/lib/features/erd/erd_view_controller.dart +++ b/lib/features/erd/erd_view_controller.dart @@ -251,6 +251,83 @@ class ErdViewController { void removeView(String id) => views.removeWhere((v) => v.id == id); + /// What the diagram shows and how it is arranged: this state with the card + /// [positions] and the viewport. + ErdSavedLayout snapshot({ + required Map positions, + required double scale, + required Offset translation, + }) => + ErdSavedLayout( + positions: Map.of(positions), + collapsed: Set.of(collapsed), + hidden: Set.of(hidden), + detail: detail, + headerColors: Map.of(headerColors), + groups: List.of(groups), + notes: List.of(notes), + scale: scale, + translation: translation, + ); + + /// Writes [current] to where it belongs: the active view, or the diagram's + /// own (*All tables*) state. [tableNames] is every table of the diagram, to + /// keep the view's tables in step with what is hidden. + void capture(ErdSavedLayout current, Iterable tableNames) { + final i = views.indexWhere((v) => v.id == activeView); + if (i < 0) { + base = current; + return; + } + views[i] = ErdSavedView( + id: views[i].id, + name: views[i].name, + tables: { + for (final t in tableNames) + if (!hidden.contains(t)) t, + }, + layout: current, + ); + } + + /// The document to store: the diagram's own state with its views and the + /// header colours as they are now. + ErdSavedLayout toStore() => base.withViews(List.of(views), activeView, + headerColors: Map.of(headerColors)); + + /// The state to show for the view [id] (null: *All tables*), or null when + /// there is no such view. Call [capture] first. + ErdSavedLayout? stateOfView(String? id, Set tableNames) { + if (id == null) return base; + final view = viewById(id); + return view?.asLayout(tableNames, headerColors); + } + + /// Saves what the diagram shows as a new view named [name] and moves into + /// it; returns its id. Call [capture] first. + String addViewFromCurrent({ + required String name, + required ErdSavedLayout current, + required Iterable tableNames, + }) { + final fromAll = viewById(activeView) == null; + // The tables hidden by hand now belong to the view; All tables shows + // every table, always. + if (fromAll) base = base.showingAll(); + final id = nextViewId(); + views.add(ErdSavedView( + id: id, + name: name, + tables: { + for (final t in tableNames) + if (!hidden.contains(t)) t, + }, + layout: current, + )); + activeView = id; + return id; + } + // ---- Pick and focus ------------------------------------------------------ /// A click on a card with no modifier: picks it and drops a picked relation. diff --git a/test/features/erd/erd_view_controller_test.dart b/test/features/erd/erd_view_controller_test.dart index d213146b..c4109c33 100644 --- a/test/features/erd/erd_view_controller_test.dart +++ b/test/features/erd/erd_view_controller_test.dart @@ -274,4 +274,89 @@ void main() { expect(other.column, isNull); }); }); + + group('saving and views', () { + const here = Offset(10, 20); + + test('a snapshot copies the state with the positions and the viewport', () { + c + ..collapsed.add('users') + ..hidden.add('tags') + ..detail = ErdDetail.keys + ..headerColors['users'] = 'type2'; + final snap = c.snapshot( + positions: {'users': here}, scale: 1.5, translation: const Offset(3, 4)); + expect(snap.positions, {'users': here}); + expect(snap.collapsed, {'users'}); + expect(snap.hidden, {'tags'}); + expect(snap.detail, ErdDetail.keys); + expect(snap.scale, 1.5); + expect(snap.translation, const Offset(3, 4)); + + // A copy: later changes do not reach it. + c.collapsed.add('orders'); + expect(snap.collapsed, {'users'}); + }); + + test('with no active view the snapshot becomes the diagram own state', () { + final snap = c.snapshot(positions: {}, scale: 1, translation: Offset.zero); + c.capture(snap, names); + expect(c.base, same(snap)); + }); + + test('with a view active the snapshot goes to that view, tables minus hidden', + () { + final id = c.addViewFromCurrent( + name: 'Sales', + current: c.snapshot(positions: {}, scale: 1, translation: Offset.zero), + tableNames: names, + ); + c.hidden.add('tags'); + final snap = c.snapshot( + positions: {'users': here}, scale: 2, translation: Offset.zero); + c.capture(snap, names); + final view = c.viewById(id)!; + expect(view.layout.positions, {'users': here}); + expect(view.tables, {'users', 'orders', 'items'}); + expect(view.name, 'Sales'); + }); + + test('a new view from All tables shows every table there afterwards', () { + c.hidden.add('tags'); + c.base = c.snapshot(positions: {}, scale: 1, translation: Offset.zero); + final id = c.addViewFromCurrent( + name: 'Core', + current: c.snapshot(positions: {}, scale: 1, translation: Offset.zero), + tableNames: names, + ); + expect(c.activeView, id); + expect(c.base.hidden, isEmpty, reason: 'All tables always shows all'); + expect(c.viewById(id)!.tables, isNot(contains('tags'))); + }); + + test('stateOfView: null is All tables, an id is its view, a stranger is none', + () { + expect(c.stateOfView(null, names.toSet()), same(c.base)); + final id = c.addViewFromCurrent( + name: 'V', + current: c.snapshot(positions: {}, scale: 1, translation: Offset.zero), + tableNames: names, + ); + expect(c.stateOfView(id, names.toSet()), isNotNull); + expect(c.stateOfView('v99', names.toSet()), isNull); + }); + + test('toStore carries the views, the active one and the colours', () { + c.headerColors['users'] = 'type4'; + final id = c.addViewFromCurrent( + name: 'V', + current: c.snapshot(positions: {}, scale: 1, translation: Offset.zero), + tableNames: names, + ); + final stored = c.toStore(); + expect(stored.views.single.id, id); + expect(stored.activeView, id); + expect(stored.headerColors, {'users': 'type4'}); + }); + }); } diff --git a/test/features/erd/erd_viewport_test.dart b/test/features/erd/erd_viewport_test.dart new file mode 100644 index 00000000..f55299a0 --- /dev/null +++ b/test/features/erd/erd_viewport_test.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/erd/erd_viewport.dart'; + +void main() { + group('zoom and pan', () { + late ErdViewport v; + setUp(() => v = ErdViewport()); + tearDown(() => v.dispose()); + + test('set and read back the scale and the translation', () { + v.set(scale: 1.5, translation: const Offset(30, 40)); + expect(v.scale, closeTo(1.5, 1e-9)); + expect(v.translation, const Offset(30, 40)); + }); + + test('zooming about a point keeps that point still', () { + v.set(scale: 1, translation: const Offset(10, 10)); + const focal = Offset(200, 100); + final before = v.toCanvas(focal); + v.zoomTo(2, focal); + expect(v.scale, closeTo(2, 1e-9)); + final after = v.toCanvas(focal); + expect(after.dx, closeTo(before.dx, 1e-9)); + expect(after.dy, closeTo(before.dy, 1e-9)); + }); + + test('the zoom stays between the floor and the maximum', () { + v.zoomTo(100, Offset.zero); + expect(v.scale, ErdViewport.maxScale); + v.zoomTo(0.0001, Offset.zero); + expect(v.scale, ErdViewport.minScale); + v.zoomFloor = 0.05; + v.zoomTo(0.0001, Offset.zero); + expect(v.scale, closeTo(0.05, 1e-9)); + }); + + test('screen and canvas positions convert both ways', () { + v.set(scale: 2, translation: const Offset(10, 20)); + expect(v.toScreen(const Offset(5, 5)), const Offset(20, 30)); + expect(v.toCanvas(const Offset(20, 30)), const Offset(5, 5)); + }); + + test('before it is laid out there is no size, no fit, no centre', () { + expect(v.size, isNull); + expect(v.fit(const Size(100, 100)), isNull); + expect(v.centre(), const Offset(60, 60)); + v.zoomBy(2); // nothing to zoom about: no change + expect(v.scale, 1); + }); + }); + + group('in a laid out viewport', () { + testWidgets('fit centres a small diagram without enlarging it', (t) async { + final v = ErdViewport(); + addTearDown(v.dispose); + await t.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: SizedBox(width: 800, height: 600, child: SizedBox(key: v.key)), + )); + expect(v.size, const Size(800, 600)); + + expect(v.fit(const Size(400, 300)), isFalse); + expect(v.scale, closeTo(1, 1e-9)); + expect(v.translation, const Offset(200, 150)); + expect(v.centre(), const Offset(200, 150)); + }); + + testWidgets('fit shrinks a big diagram and lowers the zoom floor', + (t) async { + final v = ErdViewport(); + addTearDown(v.dispose); + await t.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: SizedBox(width: 800, height: 600, child: SizedBox(key: v.key)), + )); + final moved = v.fit(const Size(16000, 12000)); + expect(moved, isTrue); + expect(v.scale, lessThan(ErdViewport.minScale)); + expect(v.zoomFloor, closeTo(v.scale, 1e-9)); + }); + + testWidgets('centreOn puts a canvas point in the middle, keeping the zoom', + (t) async { + final v = ErdViewport(); + addTearDown(v.dispose); + await t.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: SizedBox(width: 800, height: 600, child: SizedBox(key: v.key)), + )); + v.set(scale: 2, translation: Offset.zero); + v.centreOn(const Offset(1000, 500)); + expect(v.scale, closeTo(2, 1e-9)); + final c = v.toCanvas(const Offset(400, 300)); + expect(c.dx, closeTo(1000, 1e-9)); + expect(c.dy, closeTo(500, 1e-9)); + }); + }); +}