feat: add simple columns block in editor (#1038)

* feat: add simple columns block in editor

* feat: add divider between column block

* fix: add fallback logic in calculate selection function

* feat: add column width resizer

* feat: integrate columns block in editor on desktop
This commit is contained in:
Lucas 2025-02-21 12:05:56 +08:00 • committed by GitHub
commit c1e9d15572
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 562 additions and 29 deletions

View file

@ -136,6 +136,10 @@ class _DesktopEditorState extends State<DesktopEditor> {
Map<String, BlockComponentBuilder> _buildBlockComponentBuilders() { Map<String, BlockComponentBuilder> _buildBlockComponentBuilders() {
final map = { final map = {
...standardBlockComponentBuilderMap, ...standardBlockComponentBuilderMap,
// columns block
ColumnBlockKeys.type: ColumnBlockComponentBuilder(),
ColumnsBlockKeys.type: ColumnsBlockComponentBuilder(),
}; };
// customize the image block component to show a menu // customize the image block component to show a menu
map[ImageBlockKeys.type] = ImageBlockComponentBuilder( map[ImageBlockKeys.type] = ImageBlockComponentBuilder(
@ -165,7 +169,13 @@ class _DesktopEditorState extends State<DesktopEditor> {
// customize the padding // customize the padding
map.forEach((key, value) { map.forEach((key, value) {
value.configuration = value.configuration.copyWith( value.configuration = value.configuration.copyWith(
padding: (_) => const EdgeInsets.symmetric(vertical: 8.0), padding: (node) {
if (node.type == ColumnsBlockKeys.type ||
node.type == ColumnBlockKeys.type) {
return EdgeInsets.zero;
}
return const EdgeInsets.symmetric(vertical: 8.0);
},
blockSelectionAreaMargin: (_) => const EdgeInsets.symmetric( blockSelectionAreaMargin: (_) => const EdgeInsets.symmetric(
vertical: 1.0, vertical: 1.0,
), ),

View file

@ -287,29 +287,49 @@ class _DragToReorderActionState extends State<DragToReorderAction> {
final (verticalPosition, horizontalPosition, _) = position; final (verticalPosition, horizontalPosition, _) = position;
Path newPath = targetNode.path; Path newPath = targetNode.path;
// Determine the new path based on drop position
// For VerticalPosition.top, we keep the target node's path
if (verticalPosition == VerticalPosition.bottom) {
newPath = horizontalPosition == HorizontalPosition.left
? newPath.next // Insert after target node
: newPath.child(0); // Insert as first child of target node
}
// Check if the drop should be ignored
if (_shouldIgnoreDrop(node, newPath)) {
debugPrint(
'Drop ignored: node($node, ${node.path}), path($acceptedPath)',
);
return;
}
final realNode = widget.blockComponentContext.node; final realNode = widget.blockComponentContext.node;
debugPrint('Moving node($realNode, ${realNode.path}) to path($newPath)'); debugPrint('Moving node($realNode, ${realNode.path}) to path($newPath)');
// Perform the node move operation // if the horizontal position is right, we need to create a column block
final transaction = editorState.transaction; if (horizontalPosition == HorizontalPosition.right) {
transaction.moveNode(newPath, realNode); final node = columnsNode(
await editorState.apply(transaction); children: [
columnNode(
children: [targetNode.deepCopy()],
),
columnNode(
children: [realNode.deepCopy()],
),
],
);
final transaction = editorState.transaction;
transaction.insertNode(newPath, node);
transaction.deleteNode(targetNode);
transaction.deleteNode(realNode);
await editorState.apply(transaction);
} else {
// Determine the new path based on drop position
// For VerticalPosition.top, we keep the target node's path
if (verticalPosition == VerticalPosition.bottom) {
newPath = horizontalPosition == HorizontalPosition.left
? newPath.next // Insert after target node
: newPath.child(0); // Insert as first child of target node
}
// Check if the drop should be ignored
if (_shouldIgnoreDrop(node, newPath)) {
debugPrint(
'Drop ignored: node($node, ${node.path}), path($acceptedPath)',
);
return;
}
// Perform the node move operation
final transaction = editorState.transaction;
transaction.moveNode(newPath, realNode);
await editorState.apply(transaction);
}
} }
Widget _buildFeedback() { Widget _buildFeedback() {
@ -387,7 +407,7 @@ Widget _buildDropArea(
color: Colors.red, color: Colors.red,
); );
if (horizontalPosition == HorizontalPosition.right) { if (horizontalPosition == HorizontalPosition.center) {
const breakWidth = 22.0; const breakWidth = 22.0;
const padding = 8.0; const padding = 8.0;
child = Row( child = Row(
@ -406,6 +426,16 @@ Widget _buildDropArea(
), ),
], ],
); );
} else if (horizontalPosition == HorizontalPosition.right) {
return Positioned(
top: globalBlockRect.top,
height: globalBlockRect.height,
left: globalBlockRect.right - 2,
child: Container(
width: 2,
color: Colors.red,
),
);
} }
return Positioned( return Positioned(
@ -452,9 +482,10 @@ Widget _buildDropArea(
// Horizontal position // Horizontal position
if (dragOffset.dx < globalBlockRect.left + 44) { if (dragOffset.dx < globalBlockRect.left + 44) {
horizontalPosition = HorizontalPosition.left; horizontalPosition = HorizontalPosition.left;
} else { } else if (dragOffset.dx > globalBlockRect.right / 3.0 * 2.0) {
// ignore the middle here, it's not used in this example
horizontalPosition = HorizontalPosition.right; horizontalPosition = HorizontalPosition.right;
} else {
horizontalPosition = HorizontalPosition.center;
} }
// Vertical position // Vertical position

View file

@ -42,7 +42,14 @@ extension EditorStateSelection on EditorState {
sortedNodes, sortedNodes,
start, start,
end, end,
(rect) => rect.bottom <= offset.dy, match: (index, rect) {
final isMatch = rect.contains(offset);
AppFlowyEditorLog.selection.debug(
'findNodeInOffset: $index, rect: $rect, offset: $offset, isMatch: $isMatch',
);
return isMatch;
},
compare: (index, rect) => rect.bottom <= offset.dy,
); );
final filteredNodes = List.of(sortedNodes) final filteredNodes = List.of(sortedNodes)
@ -53,7 +60,14 @@ extension EditorStateSelection on EditorState {
sortedNodes, sortedNodes,
0, 0,
filteredNodes.length - 1, filteredNodes.length - 1,
(rect) => rect.right <= offset.dx, match: (index, rect) {
final isMatch = rect.contains(offset);
AppFlowyEditorLog.selection.debug(
'findNodeInOffset: $index, rect: $rect, offset: $offset, isMatch: $isMatch',
);
return isMatch;
},
compare: (index, rect) => rect.right <= offset.dx,
); );
} }
@ -81,20 +95,29 @@ extension EditorStateSelection on EditorState {
int _findCloseNode( int _findCloseNode(
List<Node> sortedNodes, List<Node> sortedNodes,
int start, int start,
int end, int end, {
bool Function(Rect rect) compare, bool Function(int index, Rect rect)? match,
) { required bool Function(int index, Rect rect) compare,
}) {
for (var i = start; i <= end; i++) {
final rect = sortedNodes[i].rect;
if (match != null && match(i, rect)) {
return i;
}
}
var min = start; var min = start;
var max = end; var max = end;
while (min <= max) { while (min <= max) {
final mid = min + ((max - min) >> 1); final mid = min + ((max - min) >> 1);
final rect = sortedNodes[mid].rect; final rect = sortedNodes[mid].rect;
if (compare(rect)) { if (compare(mid, rect)) {
min = mid + 1; min = mid + 1;
} else { } else {
max = mid - 1; max = mid - 1;
} }
} }
return min.clamp(start, end); return min.clamp(start, end);
} }
} }

View file

@ -0,0 +1,166 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
Node columnNode({
List<Node>? children,
double? width,
}) {
return Node(
type: ColumnBlockKeys.type,
children: children ?? [paragraphNode()],
attributes: {
ColumnBlockKeys.width: width,
},
);
}
class ColumnBlockKeys {
const ColumnBlockKeys._();
static const String type = 'column';
static const String width = 'width';
}
class ColumnBlockComponentBuilder extends BlockComponentBuilder {
ColumnBlockComponentBuilder({super.configuration});
@override
BlockComponentWidget build(BlockComponentContext blockComponentContext) {
final node = blockComponentContext.node;
return ColumnBlockComponent(
key: node.key,
node: node,
showActions: showActions(node),
configuration: configuration,
actionBuilder: (_, state) => actionBuilder(blockComponentContext, state),
);
}
@override
BlockComponentValidate get validate => (node) => node.children.isNotEmpty;
}
class ColumnBlockComponent extends BlockComponentStatefulWidget {
const ColumnBlockComponent({
super.key,
required super.node,
super.showActions,
super.actionBuilder,
super.configuration = const BlockComponentConfiguration(),
});
@override
State<ColumnBlockComponent> createState() => ColumnBlockComponentState();
}
class ColumnBlockComponentState extends State<ColumnBlockComponent>
with SelectableMixin, BlockComponentConfigurable {
@override
BlockComponentConfiguration get configuration => widget.configuration;
@override
Node get node => widget.node;
RenderBox? get _renderBox => context.findRenderObject() as RenderBox?;
final columnKey = GlobalKey();
late final EditorState editorState = context.read<EditorState>();
@override
void initState() {
super.initState();
}
@override
void dispose() {
super.dispose();
}
@override
Widget build(BuildContext context) {
Widget child = Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: node.children
.map(
(e) => IntrinsicHeight(
child: editorState.renderer.build(context, e),
),
)
.toList(),
);
child = Padding(
key: columnKey,
padding: padding,
child: child,
);
return child;
}
@override
Position start() => Position(path: widget.node.path);
@override
Position end() => Position(path: widget.node.path, offset: 1);
@override
Position getPositionInOffset(Offset start) => end();
@override
bool get shouldCursorBlink => false;
@override
CursorStyle get cursorStyle => CursorStyle.cover;
@override
Rect getBlockRect({
bool shiftWithBaseOffset = false,
}) {
return getRectsInSelection(Selection.invalid()).first;
}
@override
Rect? getCursorRectInPosition(
Position position, {
bool shiftWithBaseOffset = false,
}) {
final rects = getRectsInSelection(
Selection.collapsed(position),
shiftWithBaseOffset: shiftWithBaseOffset,
);
return rects.firstOrNull;
}
@override
List<Rect> getRectsInSelection(
Selection selection, {
bool shiftWithBaseOffset = false,
}) {
if (_renderBox == null) {
return [];
}
final parentBox = context.findRenderObject();
final renderBox = columnKey.currentContext?.findRenderObject();
if (parentBox is RenderBox && renderBox is RenderBox) {
return [
renderBox.localToGlobal(Offset.zero, ancestor: parentBox) &
renderBox.size,
];
}
return [Offset.zero & _renderBox!.size];
}
@override
Selection getSelectionInRange(Offset start, Offset end) =>
Selection.single(path: widget.node.path, startOffset: 0, endOffset: 1);
@override
Offset localToGlobal(Offset offset, {bool shiftWithBaseOffset = false}) =>
_renderBox!.localToGlobal(offset);
}

View file

@ -0,0 +1,87 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/plugins/blocks/columns/columns_block_constant.dart';
import 'package:flutter/material.dart';
class ColumnWidthResizer extends StatefulWidget {
const ColumnWidthResizer({
super.key,
required this.columnNode,
required this.editorState,
});
final Node columnNode;
final EditorState editorState;
@override
State<ColumnWidthResizer> createState() => _ColumnWidthResizerState();
}
class _ColumnWidthResizerState extends State<ColumnWidthResizer> {
bool isDragging = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
cursor: SystemMouseCursors.resizeLeftRight,
child: GestureDetector(
onHorizontalDragStart: _onHorizontalDragStart,
onHorizontalDragUpdate: _onHorizontalDragUpdate,
onHorizontalDragEnd: _onHorizontalDragEnd,
child: Container(
width: ColumnsBlockConstants.columnWidthResizerWidth,
color: ColumnsBlockConstants.columnWidthResizerColor,
),
),
);
}
void _onHorizontalDragStart(DragStartDetails details) {
isDragging = true;
}
void _onHorizontalDragUpdate(DragUpdateDetails details) {
if (!isDragging) {
return;
}
// update the column width in memory
final columnNode = widget.columnNode;
final rect = columnNode.rect;
final width = columnNode.attributes[ColumnBlockKeys.width] ?? rect.width;
final newWidth = width + details.delta.dx;
final transaction = widget.editorState.transaction;
transaction.updateNode(columnNode, {
...columnNode.attributes,
ColumnBlockKeys.width: newWidth.clamp(
ColumnsBlockConstants.minimumColumnWidth,
double.infinity,
),
});
final columnsNode = columnNode.parent;
if (columnsNode != null) {
transaction.updateNode(columnsNode, {
...columnsNode.attributes,
ColumnsBlockKeys.columnCount: columnsNode.children.length,
});
}
widget.editorState.apply(
transaction,
options: ApplyOptions(inMemoryUpdate: true),
);
}
void _onHorizontalDragEnd(DragEndDetails details) {
if (!isDragging) {
return;
}
// apply the transaction again to make sure the width is updated
final transaction = widget.editorState.transaction;
transaction.updateNode(widget.columnNode, {
...widget.columnNode.attributes,
});
widget.editorState.apply(transaction);
isDragging = false;
}
}

View file

@ -0,0 +1,205 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/plugins/blocks/columns/column_width_resizer.dart';
import 'package:appflowy_editor/src/plugins/blocks/columns/columns_block_constant.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
Node columnsNode({
List<Node>? children,
}) {
return Node(
type: ColumnsBlockKeys.type,
children: children ??
[
for (var i = 0; i < 2; i++)
columnNode(
children: [
paragraphNode(
text: 'Column $i',
),
],
),
],
);
}
class ColumnsBlockKeys {
const ColumnsBlockKeys._();
static const String type = 'columns';
static const String columnCount = 'column_count';
}
class ColumnsBlockComponentBuilder extends BlockComponentBuilder {
ColumnsBlockComponentBuilder({super.configuration});
@override
BlockComponentWidget build(BlockComponentContext blockComponentContext) {
final node = blockComponentContext.node;
return ColumnsBlockComponent(
key: node.key,
node: node,
showActions: showActions(node),
configuration: configuration,
actionBuilder: (_, state) => actionBuilder(blockComponentContext, state),
);
}
@override
BlockComponentValidate get validate => (node) => node.children.isNotEmpty;
}
class ColumnsBlockComponent extends BlockComponentStatefulWidget {
const ColumnsBlockComponent({
super.key,
required super.node,
super.showActions,
super.actionBuilder,
super.configuration = const BlockComponentConfiguration(),
});
@override
State<ColumnsBlockComponent> createState() => ColumnsBlockComponentState();
}
class ColumnsBlockComponentState extends State<ColumnsBlockComponent>
with SelectableMixin, BlockComponentConfigurable {
@override
BlockComponentConfiguration get configuration => widget.configuration;
@override
Node get node => widget.node;
RenderBox? get _renderBox => context.findRenderObject() as RenderBox?;
final columnsKey = GlobalKey();
late final EditorState editorState = context.read<EditorState>();
@override
void initState() {
super.initState();
}
@override
void dispose() {
super.dispose();
}
@override
Widget build(BuildContext context) {
Widget child = IntrinsicHeight(
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: _buildChildren(),
),
);
child = Padding(
key: columnsKey,
padding: padding,
child: child,
);
return child;
}
List<Widget> _buildChildren() {
final children = <Widget>[];
for (var i = 0; i < node.children.length; i++) {
final childNode = node.children[i];
final double? width = childNode.attributes[ColumnBlockKeys.width];
Widget child = editorState.renderer.build(context, childNode);
if (width != null) {
child = SizedBox(
width: width.clamp(
ColumnsBlockConstants.minimumColumnWidth,
double.infinity,
),
child: child,
);
} else {
child = Expanded(
child: child,
);
}
children.add(child);
if (i != node.children.length - 1) {
children.add(
ColumnWidthResizer(
columnNode: childNode,
editorState: editorState,
),
);
}
}
return children;
}
@override
Position start() => Position(path: widget.node.path);
@override
Position end() => Position(path: widget.node.path, offset: 1);
@override
Position getPositionInOffset(Offset start) => end();
@override
bool get shouldCursorBlink => false;
@override
CursorStyle get cursorStyle => CursorStyle.cover;
@override
Rect getBlockRect({
bool shiftWithBaseOffset = false,
}) {
return getRectsInSelection(Selection.invalid()).first;
}
@override
Rect? getCursorRectInPosition(
Position position, {
bool shiftWithBaseOffset = false,
}) {
final rects = getRectsInSelection(
Selection.collapsed(position),
shiftWithBaseOffset: shiftWithBaseOffset,
);
return rects.firstOrNull;
}
@override
List<Rect> getRectsInSelection(
Selection selection, {
bool shiftWithBaseOffset = false,
}) {
if (_renderBox == null) {
return [];
}
final parentBox = context.findRenderObject();
final renderBox = columnsKey.currentContext?.findRenderObject();
if (parentBox is RenderBox && renderBox is RenderBox) {
return [
renderBox.localToGlobal(Offset.zero, ancestor: parentBox) &
renderBox.size,
];
}
return [Offset.zero & _renderBox!.size];
}
@override
Selection getSelectionInRange(Offset start, Offset end) =>
Selection.single(path: widget.node.path, startOffset: 0, endOffset: 1);
@override
Offset localToGlobal(Offset offset, {bool shiftWithBaseOffset = false}) =>
_renderBox!.localToGlobal(offset);
}

View file

@ -0,0 +1,9 @@
import 'package:flutter/material.dart';
class ColumnsBlockConstants {
const ColumnsBlockConstants._();
static const double columnWidthResizerWidth = 4;
static const Color columnWidthResizerColor = Colors.red;
static const double minimumColumnWidth = 128;
}

View file

@ -13,3 +13,5 @@ export 'markdown/encoder/parser/parser.dart';
export 'quill_delta/quill_delta_encoder.dart'; export 'quill_delta/quill_delta_encoder.dart';
export 'word_count/word_counter_service.dart'; export 'word_count/word_counter_service.dart';
export 'pdf/html_to_pdf_encoder.dart'; export 'pdf/html_to_pdf_encoder.dart';
export 'blocks/columns/column_block_component.dart';
export 'blocks/columns/columns_block_component.dart';