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:
parent
1df3cb1a1f
commit
c1e9d15572
8 changed files with 562 additions and 29 deletions
|
|
@ -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,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
166
lib/src/plugins/blocks/columns/column_block_component.dart
Normal file
166
lib/src/plugins/blocks/columns/column_block_component.dart
Normal 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);
|
||||||
|
}
|
||||||
87
lib/src/plugins/blocks/columns/column_width_resizer.dart
Normal file
87
lib/src/plugins/blocks/columns/column_width_resizer.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
205
lib/src/plugins/blocks/columns/columns_block_component.dart
Normal file
205
lib/src/plugins/blocks/columns/columns_block_component.dart
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue