diff --git a/super_editor/example/lib/demos/example_editor/example_editor.dart b/super_editor/example/lib/demos/example_editor/example_editor.dart index 2f13857a2a..d48c1a72cf 100644 --- a/super_editor/example/lib/demos/example_editor/example_editor.dart +++ b/super_editor/example/lib/demos/example_editor/example_editor.dart @@ -414,6 +414,7 @@ class _ExampleEditorState extends State { DefaultCaretOverlayBuilder( const CaretStyle().copyWith(color: isLight ? Colors.black : Colors.redAccent), ), + _TokenBoundsOverlay(), ], selectionStyle: isLight ? defaultSelectionStyle @@ -524,3 +525,106 @@ final _darkModeStyles = [ }, ), ]; + +class _TokenBoundsOverlay implements DocumentLayerBuilder { + @override + Widget build(BuildContext context, SuperEditorContext editContext) { + print("Building token bounds overlay"); + return _AttributionBounds( + layout: editContext.documentLayout, + document: editContext.document, + selector: (a) => a == boldAttribution, + ); + } +} + +class _AttributionBounds extends StatefulWidget { + const _AttributionBounds({ + Key? key, + required this.layout, + required this.document, + required this.selector, + }) : super(key: key); + + final DocumentLayout layout; + final Document document; + final AttributionBoundsSelector selector; + + @override + State<_AttributionBounds> createState() => _AttributionBoundsState(); +} + +class _AttributionBoundsState extends State<_AttributionBounds> { + final _bounds = {}; + + @override + void initState() { + super.initState(); + + _findBounds(); + widget.document.addListener(_onDocumentChange); + } + + @override + void dispose() { + widget.document.removeListener(_onDocumentChange); + super.dispose(); + } + + void _onDocumentChange(changeLog) { + if (!mounted) { + return; + } + + setState(() { + _findBounds(); + }); + } + + void _findBounds() { + _bounds.clear(); + + for (final node in widget.document.nodes) { + if (node is! TextNode) { + continue; + } + + final spans = node.text.getAttributionSpansInRange( + attributionFilter: widget.selector, + range: SpanRange(start: 0, end: node.text.text.length - 1), + ); + + final documentRanges = spans.map( + (span) => DocumentRange( + start: DocumentPosition(nodeId: node.id, nodePosition: TextNodePosition(offset: span.start)), + end: DocumentPosition(nodeId: node.id, nodePosition: TextNodePosition(offset: span.end + 1)), + ), + ); + + _bounds.addAll(documentRanges.map( + (range) => widget.layout.getRectForSelection(range.start, range.end) ?? Rect.zero, + )); + } + } + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: Stack( + children: [ + for (final bound in _bounds) // + Positioned.fromRect( + rect: bound, + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: Colors.blue), + ), + ), + ), + ], + ), + ); + } +} + +typedef AttributionBoundsSelector = bool Function(Attribution attribution); diff --git a/super_editor/example/lib/main.dart b/super_editor/example/lib/main.dart index 3091974f8b..ccd938c1c3 100644 --- a/super_editor/example/lib/main.dart +++ b/super_editor/example/lib/main.dart @@ -46,7 +46,7 @@ Future main() async { // editorDocLog, // editorStyleLog, // textFieldLog, - appLog, + // appLog, }); runApp(SuperEditorDemoApp()); diff --git a/super_editor/lib/src/default_editor/super_editor.dart b/super_editor/lib/src/default_editor/super_editor.dart index 00da01c782..640e9096bd 100644 --- a/super_editor/lib/src/default_editor/super_editor.dart +++ b/super_editor/lib/src/default_editor/super_editor.dart @@ -707,8 +707,9 @@ class SuperEditorState extends State { showDebugPaint: widget.debugPaint.layout, ), overlays: [ - for (final overlayBuilder in widget.documentOverlayBuilders) // - overlayBuilder.build(context, editContext), + if (_docLayoutKey.currentState != null) // + for (final overlayBuilder in widget.documentOverlayBuilders) // + overlayBuilder.build(context, editContext), ], ); case DocumentGestureMode.android: diff --git a/super_text_layout/example/lib/attributed_follower.dart b/super_text_layout/example/lib/attributed_follower.dart new file mode 100644 index 0000000000..947b64757d --- /dev/null +++ b/super_text_layout/example/lib/attributed_follower.dart @@ -0,0 +1,189 @@ +import 'package:attributed_text/attributed_text.dart'; +import 'package:flutter/material.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; +import 'package:overlord/follow_the_leader.dart'; +import 'package:overlord/overlord.dart'; +import 'package:super_text_layout/super_text_layout.dart'; + +class AttributedFollowerDemo extends StatefulWidget { + const AttributedFollowerDemo({super.key}); + + @override + State createState() => _AttributedFollowerDemoState(); +} + +class _AttributedFollowerDemoState extends State { + static const token = NamedAttribution("token"); + + static const _textStyle = TextStyle( + color: Color(0xFF444444), + fontFamily: 'Roboto', + fontSize: 20, + height: 1.4, + ); + + final _attributedText = AttributedText( + text: "This is some text that's tokenized like tags and mentions", + spans: AttributedSpans(attributions: [ + const SpanMarker(attribution: token, offset: 8, markerType: SpanMarkerType.start), + const SpanMarker(attribution: token, offset: 16, markerType: SpanMarkerType.end), + const SpanMarker(attribution: token, offset: 25, markerType: SpanMarkerType.start), + const SpanMarker(attribution: token, offset: 33, markerType: SpanMarkerType.end), + const SpanMarker(attribution: token, offset: 40, markerType: SpanMarkerType.start), + const SpanMarker(attribution: token, offset: 43, markerType: SpanMarkerType.end), + const SpanMarker(attribution: token, offset: 49, markerType: SpanMarkerType.start), + const SpanMarker(attribution: token, offset: 56, markerType: SpanMarkerType.end), + ]), + ); + late final TextSpan _richText; + Set _tokenRanges = {}; + + final _leaderLink = LeaderLink(); + + int _spanToFollow = 0; + + @override + void initState() { + super.initState(); + + _createRichText(); + _calculateTokenRanges(); + } + + void _createRichText() { + _richText = TextSpan(children: [], style: _textStyle); + int textOffset = 0; + + final spans = _attributedText.getAttributionSpansInRange( + attributionFilter: (a) => a == token, + range: SpanRange(start: 0, end: _attributedText.text.length), + ); + + for (final span in spans) { + if (span.start > textOffset) { + _richText.children!.add( + TextSpan(text: _attributedText.text.substring(textOffset, span.start)), + ); + textOffset = span.start; + } + + _richText.children!.add(TextSpan( + text: _attributedText.text.substring(span.start, span.end + 1), + style: const TextStyle(color: Colors.blue), + )); + textOffset = span.end + 1; + } + } + + void _calculateTokenRanges() { + _tokenRanges = _attributedText + .getAttributionSpansInRange( + attributionFilter: (a) => a == token, range: SpanRange(start: 0, end: _attributedText.text.length)) + .map((span) => TextSelection(baseOffset: span.start, extentOffset: span.end + 1)) + .toSet(); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildText(), + _buildSpanSelectionControls(), + ], + ), + Follower.withAligner( + link: _leaderLink, + aligner: CupertinoPopoverToolbarAligner(), + child: CupertinoPopoverToolbar( + focalPoint: LeaderMenuFocalPoint(link: _leaderLink), + children: [ + CupertinoPopoverToolbarMenuItem(label: "Copy"), + CupertinoPopoverToolbarMenuItem(label: "Cut"), + CupertinoPopoverToolbarMenuItem(label: "Paste"), + ], + ), + ), + ], + ); + } + + Widget _buildText() { + return SuperText( + richText: _richText, + layerBeneathBuilder: boundingBoxesLayer(_tokenRanges, (i) => i == _spanToFollow ? _leaderLink : null), + ); + } + + Widget _buildSpanSelectionControls() { + return Row( + children: [ + const Spacer(), + IconButton( + icon: const Icon(Icons.chevron_left), + onPressed: _spanToFollow > 0 + ? () { + setState(() { + _spanToFollow -= 1; + }); + } + : null, + ), + IconButton( + icon: const Icon(Icons.chevron_right), + onPressed: _spanToFollow < _tokenRanges.length - 1 + ? () { + setState(() { + _spanToFollow += 1; + }); + } + : null, + ), + ], + ); + } +} + +/// Returns a [SuperTextLayerBuilder] that builds invisible bounding box widgets around +/// each of the given [ranges]. +SuperTextLayerBuilder boundingBoxesLayer(Set ranges, BoundingBoxLinker linker) { + return (context, textLayout) { + final tokenRectsByAttribution = ranges + .map((range) => textLayout.getBoxesForSelection( + TextSelection(baseOffset: range.start, extentOffset: range.end), + )) + .map((boxes) => boxes.map((box) => box.toRect())); + + final tokenRects = [ + for (final rects in tokenRectsByAttribution) // + ...rects, + ]; + + final rectWidgets = []; + for (int i = 0; i < tokenRects.length; i += 1) { + final link = linker(i); + rectWidgets.add( + link != null + ? Leader( + link: link, + child: const SizedBox(), + ) + : const SizedBox(), + ); + } + + return Stack( + children: [ + for (int i = 0; i < rectWidgets.length; i += 1) + Positioned.fromRect( + rect: tokenRects[i], + child: rectWidgets[i], + ), + ], + ); + }; +} + +typedef BoundingBoxLinker = LeaderLink? Function(int spanIndex); diff --git a/super_text_layout/example/lib/main.dart b/super_text_layout/example/lib/main.dart index caa7ad14af..b0df51be2b 100644 --- a/super_text_layout/example/lib/main.dart +++ b/super_text_layout/example/lib/main.dart @@ -1,3 +1,4 @@ +import 'package:example/attributed_follower.dart'; import 'package:example/typing_robot.dart'; import 'package:flutter/material.dart'; import 'package:super_text_layout/super_text_layout.dart'; @@ -48,38 +49,43 @@ class _SuperTextExampleScreenState extends State with Ti @override Widget build(BuildContext context) { return Scaffold( - body: SingleChildScrollView( - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 500), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildHeader("Welcome to super_text_layout"), - // SuperTextWithSelection examples - _buildSubHeader("SuperTextWithSelection Widget"), - _buildDescription( - "SuperTextWithSelection is a product-level widget that renders text with traditional user selections. If you want to build a custom text decoration experience, see SuperText."), - _buildSuperTextWithSelectionStaticSingle(), - _buildSuperTextWithSelectionRobot(), - _buildSuperTextWithSelectionStaticMulti(), - const SizedBox(height: 48), - // SuperText examples - _buildSubHeader("SuperText Widget"), - _buildDescription( - "SuperText is a platform, upon which you can build various text experiences. A SuperText widget allows you to build an arbitrary UI beneath the text, and above the text."), - _buildCharacterRainbow(), - _buildSingleCaret(), - _buildSingleSelectionHighlight(), - _buildSingleSelectionHighlightRainbow(), - _buildMultiUserSelections(), - _buildEmptySelection(), - const SizedBox(height: 48), - ], + body: Stack( + children: [ + SingleChildScrollView( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 500), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader("Welcome to super_text_layout"), + // SuperTextWithSelection examples + _buildSubHeader("SuperTextWithSelection Widget"), + _buildDescription( + "SuperTextWithSelection is a product-level widget that renders text with traditional user selections. If you want to build a custom text decoration experience, see SuperText."), + const AttributedFollowerDemo(), + _buildSuperTextWithSelectionStaticSingle(), + _buildSuperTextWithSelectionRobot(), + _buildSuperTextWithSelectionStaticMulti(), + const SizedBox(height: 48), + // SuperText examples + _buildSubHeader("SuperText Widget"), + _buildDescription( + "SuperText is a platform, upon which you can build various text experiences. A SuperText widget allows you to build an arbitrary UI beneath the text, and above the text."), + _buildCharacterRainbow(), + _buildSingleCaret(), + _buildSingleSelectionHighlight(), + _buildSingleSelectionHighlightRainbow(), + _buildMultiUserSelections(), + _buildEmptySelection(), + const SizedBox(height: 48), + ], + ), + ), ), ), - ), + ], ), ); } diff --git a/super_text_layout/example/macos/Runner.xcodeproj/project.pbxproj b/super_text_layout/example/macos/Runner.xcodeproj/project.pbxproj index c84862c675..d9333e4704 100644 --- a/super_text_layout/example/macos/Runner.xcodeproj/project.pbxproj +++ b/super_text_layout/example/macos/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 51; + objectVersion = 54; objects = { /* Begin PBXAggregateTarget section */ @@ -235,6 +235,7 @@ /* Begin PBXShellScriptBuildPhase section */ 3399D490228B24CF009A79C7 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); @@ -344,7 +345,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.11; + MACOSX_DEPLOYMENT_TARGET = 10.14; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -423,7 +424,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.11; + MACOSX_DEPLOYMENT_TARGET = 10.14; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -470,7 +471,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.11; + MACOSX_DEPLOYMENT_TARGET = 10.14; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; diff --git a/super_text_layout/example/pubspec.lock b/super_text_layout/example/pubspec.lock index eecc86e3aa..6c924f8248 100644 --- a/super_text_layout/example/pubspec.lock +++ b/super_text_layout/example/pubspec.lock @@ -34,12 +34,11 @@ packages: source: hosted version: "2.11.0" attributed_text: - dependency: transitive + dependency: "direct main" description: - name: attributed_text - sha256: e43495051b63e6cdbe96aa62123974074cca109d9c56f74ce2ffaec8060e044e - url: "https://pub.dev" - source: hosted + path: "../../attributed_text" + relative: true + source: path version: "0.2.2" boolean_selector: dependency: transitive @@ -69,10 +68,10 @@ packages: dependency: transitive description: name: collection - sha256: f092b211a4319e98e5ff58223576de6c2803db36221657b46c82574721240687 + sha256: "4a07be6cb69c84d677a6c3096fcf960cc3285a8330b4603e0d463d15d9bd934c" url: "https://pub.dev" source: hosted - version: "1.17.2" + version: "1.17.1" convert: dependency: transitive description: @@ -131,6 +130,14 @@ packages: description: flutter source: sdk version: "0.0.0" + follow_the_leader: + dependency: "direct main" + description: + name: follow_the_leader + sha256: "4e74bcf1ed3b4ce9c6743ee9f24bc68c0cf017ca3731d849018eb083c60687fe" + url: "https://pub.dev" + source: hosted + version: "0.0.4+2" frontend_server_client: dependency: transitive description: @@ -199,18 +206,18 @@ packages: dependency: transitive description: name: matcher - sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e" + sha256: "6501fbd55da300384b768785b83e5ce66991266cec21af89ab9ae7f5ce1c4cbb" url: "https://pub.dev" source: hosted - version: "0.12.16" + version: "0.12.15" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41" + sha256: d92141dc6fe1dad30722f9aa826c7fbc896d021d792f80678280601aff8cf724 url: "https://pub.dev" source: hosted - version: "0.5.0" + version: "0.2.0" meta: dependency: transitive description: @@ -235,6 +242,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" + overlord: + dependency: "direct main" + description: + name: overlord + sha256: "7fa6a83455b7da5c66a16320c02783d110c574a6e6c511750c662dbadfe9399f" + url: "https://pub.dev" + source: hosted + version: "0.0.3+2" package_config: dependency: transitive description: @@ -324,10 +339,10 @@ packages: dependency: transitive description: name: source_span - sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + sha256: dd904f795d4b4f3b870833847c461801f6750a9fa8e61ea5ac53f9422b31f250 url: "https://pub.dev" source: hosted - version: "1.10.0" + version: "1.9.1" stack_trace: dependency: transitive description: @@ -371,26 +386,26 @@ packages: dependency: transitive description: name: test - sha256: "13b41f318e2a5751c3169137103b60c584297353d4b1761b66029bae6411fe46" + sha256: "3dac9aecf2c3991d09b9cdde4f98ded7b30804a88a0d7e4e7e1678e78d6b97f4" url: "https://pub.dev" source: hosted - version: "1.24.3" + version: "1.24.1" test_api: dependency: transitive description: name: test_api - sha256: "75760ffd7786fffdfb9597c35c5b27eaeec82be8edfb6d71d32651128ed7aab8" + sha256: eb6ac1540b26de412b3403a163d919ba86f6a973fe6cc50ae3541b80092fdcfb url: "https://pub.dev" source: hosted - version: "0.6.0" + version: "0.5.1" test_core: dependency: transitive description: name: test_core - sha256: "99806e9e6d95c7b059b7a0fc08f07fc53fabe54a829497f0d9676299f1e8637e" + sha256: "5138dbffb77b2289ecb12b81c11ba46036590b72a64a7a90d6ffb880f1a29e93" url: "https://pub.dev" source: hosted - version: "0.5.3" + version: "0.5.1" typed_data: dependency: transitive description: diff --git a/super_text_layout/example/pubspec.yaml b/super_text_layout/example/pubspec.yaml index ee028463f4..3b0e38b3db 100644 --- a/super_text_layout/example/pubspec.yaml +++ b/super_text_layout/example/pubspec.yaml @@ -6,7 +6,7 @@ publish_to: 'none' version: 1.0.0+1 environment: - sdk: ">=2.16.2 <3.0.0" + sdk: ">=3.0.0 <4.0.0" dependencies: flutter: @@ -15,6 +15,14 @@ dependencies: super_text_layout: path: ../ + attributed_text: any + follow_the_leader: ^0.0.4+2 + overlord: ^0.0.3+2 + +dependency_overrides: + attributed_text: + path: ../../attributed_text + dev_dependencies: flutter_test: sdk: flutter diff --git a/super_text_layout/lib/src/super_text.dart b/super_text_layout/lib/src/super_text.dart index d1ac2c219b..1780bed8ae 100644 --- a/super_text_layout/lib/src/super_text.dart +++ b/super_text_layout/lib/src/super_text.dart @@ -67,7 +67,7 @@ class SuperText extends StatefulWidget { } @visibleForTesting -class SuperTextState extends State with ProseTextBlock { +class SuperTextState extends State implements ProseTextBlock { final _textLayoutKey = GlobalKey(); @override ProseTextLayout get textLayout => RenderSuperTextLayout.textLayoutFrom(_textLayoutKey)!; diff --git a/super_text_layout/lib/src/text_layout.dart b/super_text_layout/lib/src/text_layout.dart index fce0e1eadc..200aaf6cd8 100644 --- a/super_text_layout/lib/src/text_layout.dart +++ b/super_text_layout/lib/src/text_layout.dart @@ -129,7 +129,7 @@ abstract class ProseTextBlock { /// declarations to declare their [State] type without needing access to every /// different widget's [State] class, so long as any such widget's [State] class /// extends this class. -abstract class ProseTextState extends State with ProseTextBlock {} +abstract class ProseTextState extends State implements ProseTextBlock {} /// A [TextLayout] that includes queries that pertain specifically to /// prose-style text, i.e., regular human-to-human text - not code, diff --git a/super_text_layout/pubspec.yaml b/super_text_layout/pubspec.yaml index abb349b381..c2af826309 100644 --- a/super_text_layout/pubspec.yaml +++ b/super_text_layout/pubspec.yaml @@ -4,7 +4,7 @@ version: 0.1.6 homepage: https://github.com/superlistapp/super_editor environment: - sdk: ">=2.12.0 <4.0.0" + sdk: ">=3.0.0 <4.0.0" flutter: ">=1.17.0" dependencies: