feat: support block wrapper (#1101)

This commit is contained in:
Lucas 2025-05-07 10:28:18 +08:00 • committed by GitHub
commit 470c4e77c7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 326 additions and 12 deletions

View file

@ -4,6 +4,7 @@ import 'dart:io';
import 'dart:math';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:example/pages/animated_markdown_page.dart';
import 'package:example/pages/auto_complete_editor.dart';
import 'package:example/pages/auto_expand_editor.dart';
import 'package:example/pages/collab_editor.dart';
@ -185,6 +186,14 @@ class _HomePageState extends State<HomePage> {
),
);
}),
_buildListTile(context, 'Animated Markdown Editor', () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const AnimatedMarkdownPage(),
),
);
}),
_buildListTile(context, 'Auto complete Editor', () {
Navigator.push(
context,

View file

@ -0,0 +1,270 @@
import 'dart:async';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
class AnimatedMarkdownPage extends StatefulWidget {
const AnimatedMarkdownPage({super.key});
@override
State<AnimatedMarkdownPage> createState() => _AnimatedMarkdownPageState();
}
class _AnimatedMarkdownPageState extends State<AnimatedMarkdownPage>
with TickerProviderStateMixin {
int offset = 0;
String markdown = '';
bool isTimerActive = false;
late Timer markdownInputTimer;
late Timer markdownOutputTimer;
late EditorState editorState;
late EditorScrollController scrollController;
// animation tweens
final Map<String, (AnimationController, Animation<double>)> _animations = {};
@override
void initState() {
super.initState();
editorState = _parseMarkdown(markdown);
scrollController = EditorScrollController(
editorState: editorState,
shrinkWrap: true,
);
}
@override
void dispose() {
scrollController.dispose();
editorState.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SingleChildScrollView(
child: IntrinsicHeight(
child: AppFlowyEditor(
editorState: editorState,
editorScrollController: scrollController,
shrinkWrap: true,
// the editor is not editable in the chat
editable: false,
disableAutoScroll: true,
editorStyle: const EditorStyle.desktop(),
blockWrapper: (
context, {
required Node node,
required Widget child,
}) {
if (!_animations.containsKey(node.id)) {
final controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 2000),
);
final fade = Tween<double>(
begin: 0,
end: 1,
).animate(controller);
_animations[node.id] = (controller, fade);
controller.forward();
}
final (controller, fade) = _animations[node.id]!;
return AnimatedBuilder(
animation: fade,
builder: (context, childWidget) {
return ShaderMask(
shaderCallback: (Rect bounds) {
return LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
stops: [fade.value, fade.value],
colors: const [
Colors.white,
Colors.transparent,
],
).createShader(bounds);
},
blendMode: BlendMode.dstIn,
child: Opacity(
opacity: fade.value,
child: childWidget,
),
);
},
child: child,
);
},
),
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
if (isTimerActive) {
_stopTimer();
} else {
_startTimer();
}
},
child: isTimerActive
? const Icon(Icons.stop)
: const Icon(Icons.play_arrow),
),
);
}
void _startTimer() {
if (isTimerActive) {
// stop the timer and reset the offset
_stopTimer();
return;
}
isTimerActive = true;
markdownInputTimer =
Timer.periodic(const Duration(milliseconds: 50), (timer) {
markdown = _markdown.substring(0, offset);
offset += 30;
if (offset >= _markdown.length) {
timer.cancel();
}
});
markdownOutputTimer =
Timer.periodic(const Duration(milliseconds: 20), (timer) {
if (offset >= _markdown.length) {
return;
}
markdown = _markdown.substring(0, offset);
final editorState = _parseMarkdown(
markdown,
previousDocument: this.editorState.document,
);
if (editorState.document.last?.id != this.editorState.document.last?.id) {
setState(() {
this.editorState.dispose();
this.editorState = editorState;
scrollController.dispose();
scrollController = EditorScrollController(
editorState: editorState,
shrinkWrap: true,
);
});
}
});
}
void _stopTimer() {
if (!isTimerActive) {
return;
}
isTimerActive = false;
markdownInputTimer.cancel();
markdownOutputTimer.cancel();
setState(() {
markdown = '';
offset = 0;
final editorState = _parseMarkdown(
markdown,
previousDocument: this.editorState.document,
);
this.editorState.dispose();
this.editorState = editorState;
scrollController.dispose();
scrollController = EditorScrollController(
editorState: editorState,
shrinkWrap: true,
);
_animations.clear();
});
}
EditorState _parseMarkdown(
String markdown, {
Document? previousDocument,
}) {
// merge the nodes from the previous document with the new document to keep the same node ids
final document = markdownToDocument(markdown);
final documentIterator = NodeIterator(
document: document,
startNode: document.root,
);
if (previousDocument != null) {
final previousDocumentIterator = NodeIterator(
document: previousDocument,
startNode: previousDocument.root,
);
while (
documentIterator.moveNext() && previousDocumentIterator.moveNext()) {
final currentNode = documentIterator.current;
final previousNode = previousDocumentIterator.current;
if (currentNode.path.equals(previousNode.path)) {
currentNode.id = previousNode.id;
}
}
}
final editorState = EditorState(document: document);
return editorState;
}
}
const _markdown = '''# AnimationGPT Export Options
* AnimationGPT typically supports multiple export formats to accommodate different use cases and platforms
Here's what you should know about exporting animations from AnimationGPT:
* **Common Export Formats:**
* **MP4 video** - Standard video format with good quality and compression
* **GIF** - Perfect for simple animations to share on social media or messaging apps
* **WebM** - Web-optimized video format that works well for online embedding
* **PNG sequence** - Individual frames for further editing in other software
* **Quality and Resolution Options:**
* Most exports allow selecting resolution (720p, 1080p, etc.)
* Quality settings to balance file size and visual fidelity
* Frame rate options (24fps, 30fps, 60fps) depending on your needs
* Some premium tiers may offer higher resolution exports
* **Platform-Specific Exports:**
* Social media optimized presets (Instagram, TikTok, YouTube)
* Aspect ratio options (16:9, 1:1, 9:16 for stories/reels)
* File size optimization for different platforms
* **Embedding Options:**
* HTML embed codes for websites and blogs
* iFrame support for content management systems
* API integration for more advanced implementations (may require developer knowledge)
* **Sharing Features:**
* Direct sharing to social media platforms
* Shareable links to your animations
* Team/collaboration sharing options in some versions
* **Export Limitations:**
* Free tier users may have watermarks or limited export options
* Higher resolution exports might require premium subscriptions
* Some formats may have file size restrictions
* **Tips for Exporting:**
* Choose GIFs for simple, short animations (under 10 seconds)
* Use MP4 for longer animations with sound
* Consider your end platform before choosing export settings
* Test your animation on the intended platform to ensure it displays correctly
Most users find the export process straightforward with a simple interface for selecting format, quality, and destination. If you have specific platform requirements, check the export presets to see if AnimationGPT offers optimized settings for your needs."
''';

View file

@ -27,6 +27,7 @@ class PageBlockComponentBuilder extends BlockComponentBuilder {
node: blockComponentContext.node,
header: blockComponentContext.header,
footer: blockComponentContext.footer,
wrapper: blockComponentContext.wrapper,
);
}
}
@ -41,10 +42,12 @@ class PageBlockComponent extends BlockComponentStatelessWidget {
super.configuration = const BlockComponentConfiguration(),
this.header,
this.footer,
this.wrapper,
});
final Widget? header;
final Widget? footer;
final BlockComponentWrapper? wrapper;
@override
Widget build(BuildContext context) {
@ -64,14 +67,20 @@ class PageBlockComponent extends BlockComponentStatelessWidget {
children: [
if (header != null) header!,
...items.map(
(e) => Container(
constraints: BoxConstraints(
maxWidth:
editorState.editorStyle.maxWidth ?? double.infinity,
),
padding: editorState.editorStyle.padding,
child: editorState.renderer.build(context, e),
),
(e) {
Widget child = editorState.renderer.build(context, e);
if (wrapper != null) {
child = wrapper!(context, node: e, child: child);
}
return Container(
constraints: BoxConstraints(
maxWidth:
editorState.editorStyle.maxWidth ?? double.infinity,
),
padding: editorState.editorStyle.padding,
child: child,
);
},
),
if (footer != null) footer!,
],
@ -102,16 +111,22 @@ class PageBlockComponent extends BlockComponentStatelessWidget {
);
}
final node = items[index - (header != null ? 1 : 0)];
Widget child = editorState.renderer.build(
context,
node,
);
if (wrapper != null) {
child = wrapper!(context, node: node, child: child);
}
return Center(
child: Container(
constraints: BoxConstraints(
maxWidth: editorState.editorStyle.maxWidth ?? double.infinity,
),
padding: editorState.editorStyle.padding,
child: editorState.renderer.build(
context,
items[index - (header != null ? 1 : 0)],
),
child: child,
),
);
},

View file

@ -47,6 +47,7 @@ class AppFlowyEditor extends StatefulWidget {
this.disableAutoScroll = false,
this.autoScrollEdgeOffset = appFlowyEditorAutoScrollEdgeOffset,
this.documentRules = const [],
this.blockWrapper,
}) : blockComponentBuilders =
blockComponentBuilders ?? standardBlockComponentBuilderMap,
characterShortcutEvents =
@ -230,6 +231,11 @@ class AppFlowyEditor extends StatefulWidget {
///
final List<DocumentRule> documentRules;
/// Block wrapper
///
/// Wrap the block component with a widget.
final BlockComponentWrapper? blockWrapper;
@override
State<AppFlowyEditor> createState() => _AppFlowyEditorState();
}
@ -316,6 +322,7 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
editorState.document.root,
header: widget.header,
footer: widget.footer,
wrapper: widget.blockWrapper,
);
if (!widget.disableKeyboardService) {

View file

@ -1,12 +1,19 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
typedef BlockComponentWrapper = Widget Function(
BuildContext context, {
required Node node,
required Widget child,
});
class BlockComponentContext {
const BlockComponentContext(
this.buildContext,
this.node, {
this.header,
this.footer,
this.wrapper,
});
final BuildContext buildContext;
@ -15,4 +22,7 @@ class BlockComponentContext {
/// the header and the footer only work for root node.
final Widget? header;
final Widget? footer;
/// Wrap the block component with a widget.
final BlockComponentWrapper? wrapper;
}

View file

@ -103,6 +103,7 @@ abstract class BlockComponentRendererService {
Node node, {
Widget? header,
Widget? footer,
BlockComponentWrapper? wrapper,
});
List<Widget> buildList(
@ -130,12 +131,14 @@ class BlockComponentRenderer extends BlockComponentRendererService {
Node node, {
Widget? header,
Widget? footer,
BlockComponentWrapper? wrapper,
}) {
final blockComponentContext = BlockComponentContext(
buildContext,
node,
header: header,
footer: footer,
wrapper: wrapper,
);
final errorBuilder = _builders[errorBlockComponentBuilderKey];
final builder = blockComponentBuilder(node.type);