-
-
Notifications
You must be signed in to change notification settings - Fork 4.1k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: add outline block #2750
feat: add outline block #2750
Changes from 3 commits
a14345f
cc7634f
653bf8b
50a1df0
01ef7df
d5a556a
7826664
ee65078
54e6b38
f0cc858
f4961fd
0bbfb2c
be490e3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -0,0 +1,232 @@ | ||||||
import 'dart:async'; | ||||||
|
||||||
import 'package:appflowy_editor/appflowy_editor.dart'; | ||||||
import 'package:flutter/material.dart'; | ||||||
import 'package:provider/provider.dart'; | ||||||
|
||||||
class OutlineBlockKeys { | ||||||
const OutlineBlockKeys._(); | ||||||
|
||||||
static const String type = 'outline_block'; | ||||||
} | ||||||
|
||||||
// defining the callout block menu item for selection | ||||||
SelectionMenuItem outlineItem = SelectionMenuItem.node( | ||||||
name: 'Outline', | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. please use l10n instead. Adding 'outline' to There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I checked out other plugins for reference but no one uses i10n for the
"outline": {
"name": "Outline",
"heading": "Table of Contents"
} There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||
iconData: Icons.clear_all, | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. clear_all? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The list_alt looks better. |
||||||
keywords: ['outline', 'table of contents'], | ||||||
nodeBuilder: (editorState) => outlineBlockNode(), | ||||||
replace: (_, node) => node.delta?.isEmpty ?? false, | ||||||
); | ||||||
|
||||||
Node outlineBlockNode() { | ||||||
return Node( | ||||||
type: OutlineBlockKeys.type, | ||||||
); | ||||||
} | ||||||
|
||||||
class OutlineBlockComponentBuilder extends BlockComponentBuilder { | ||||||
OutlineBlockComponentBuilder({ | ||||||
this.configuration = const BlockComponentConfiguration(), | ||||||
}); | ||||||
|
||||||
@override | ||||||
final BlockComponentConfiguration configuration; | ||||||
|
||||||
@override | ||||||
BlockComponentWidget build(BlockComponentContext blockComponentContext) { | ||||||
final node = blockComponentContext.node; | ||||||
return OutlineBlockWidget( | ||||||
key: node.key, | ||||||
node: node, | ||||||
configuration: configuration, | ||||||
showActions: showActions(node), | ||||||
actionBuilder: (context, state) => actionBuilder( | ||||||
blockComponentContext, | ||||||
state, | ||||||
), | ||||||
); | ||||||
} | ||||||
|
||||||
@override | ||||||
bool validate(Node node) => true; | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. validate the outline_block.children must be empty. |
||||||
} | ||||||
|
||||||
class OutlineBlockWidget extends BlockComponentStatefulWidget { | ||||||
const OutlineBlockWidget({ | ||||||
super.key, | ||||||
required super.node, | ||||||
super.showActions, | ||||||
super.actionBuilder, | ||||||
super.configuration = const BlockComponentConfiguration(), | ||||||
}); | ||||||
|
||||||
@override | ||||||
State<OutlineBlockWidget> createState() => _OutlineBlockWidgetState(); | ||||||
} | ||||||
|
||||||
class _OutlineBlockWidgetState extends State<OutlineBlockWidget> | ||||||
with BlockComponentConfigurable { | ||||||
@override | ||||||
BlockComponentConfiguration get configuration => widget.configuration; | ||||||
|
||||||
@override | ||||||
Node get node => widget.node; | ||||||
|
||||||
late EditorState editorState = context.read<EditorState>(); | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Late but you assign it a value immediately? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah fair enough, I'm not used to this way of accessing buildcontext, never used it much tbh. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Okay, I'll change it to more commonly found syntax: late EditorState editorState;
@override
void initState() {
super.initState();
editorState = context.read<EditorState>();
} Let me know if this looks good. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Actually, both of them look good to me. I prefer to the existing one, because no need to override the |
||||||
late Stream<Transaction> stream; | ||||||
|
||||||
@override | ||||||
void initState() { | ||||||
super.initState(); | ||||||
|
||||||
stream = editorState.transactionStream; | ||||||
} | ||||||
|
||||||
@override | ||||||
Widget build(BuildContext context) { | ||||||
Widget child = _buildOutlineBlock(); | ||||||
|
||||||
return StreamBuilder( | ||||||
stream: stream, | ||||||
builder: (context, snapshot) { | ||||||
if (widget.showActions && widget.actionBuilder != null) { | ||||||
child = BlockComponentActionWrapper( | ||||||
node: widget.node, | ||||||
actionBuilder: widget.actionBuilder!, | ||||||
child: _buildOutlineBlock(), | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It is not advisable to use 'child' here. The widget will not be updated within the builder if 'child' is returned; instead, we should rebuild the widget. The below code is wrong too. It should return |
||||||
); | ||||||
} | ||||||
return child; | ||||||
}, | ||||||
); | ||||||
} | ||||||
|
||||||
Widget _buildOutlineBlock() { | ||||||
final headingNodes = getHeadingNodes(); | ||||||
return Container( | ||||||
padding: const EdgeInsets.symmetric( | ||||||
horizontal: 15.0, | ||||||
vertical: 20.0, | ||||||
), | ||||||
decoration: BoxDecoration( | ||||||
border: Border.all(color: Colors.white54), | ||||||
borderRadius: BorderRadius.circular(15.0), | ||||||
), | ||||||
child: Column( | ||||||
crossAxisAlignment: CrossAxisAlignment.start, | ||||||
children: [ | ||||||
Text( | ||||||
"TABLE OF CONTENTS: ", | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. l10n There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just for clarification would the following do fine? "mathEquation": {
"addMathEquation": "Add Math Equation",
"editMathEquation": "Edit Math Equation"
},
"outline":{
"heading": "TABLE OF CONTENTS:"
},
|
||||||
style: editorState.editorStyle.textStyleConfiguration.text, | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You're right. we should use There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This PR looks good to me, except for the missing test. I will merge it once you add the tests and ensure that all the CI passes. |
||||||
), | ||||||
const Divider( | ||||||
color: Colors.white54, | ||||||
), | ||||||
Column( | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do you need a Column here? You're already inside a Column with the exact same crossAxisAlignment. Just do
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah simply spreading the list of widgets would be a good idea. Thanks ✨ |
||||||
crossAxisAlignment: CrossAxisAlignment.start, | ||||||
children: headingNodes | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
No need to have a variable with this if you only use it in one place. |
||||||
.map( | ||||||
(e) => OutlineItemWidget(node: e), | ||||||
) | ||||||
.toList(), | ||||||
) | ||||||
], | ||||||
), | ||||||
); | ||||||
} | ||||||
|
||||||
Iterable<Node> getHeadingNodes() { | ||||||
final children = editorState.document.root.children; | ||||||
return children.where((element) => element.type == HeadingBlockKeys.type); | ||||||
} | ||||||
} | ||||||
|
||||||
class OutlineItemWidget extends StatelessWidget { | ||||||
OutlineItemWidget({ | ||||||
super.key, | ||||||
required this.node, | ||||||
}) { | ||||||
assert(node.type == HeadingBlockKeys.type); | ||||||
} | ||||||
|
||||||
final Node node; | ||||||
|
||||||
@override | ||||||
Widget build(BuildContext context) { | ||||||
final editorState = context.read<EditorState>(); | ||||||
return MouseRegion( | ||||||
cursor: SystemMouseCursors.click, | ||||||
child: GestureDetector( | ||||||
onTap: () { | ||||||
// when clicked scroll the view to the heading | ||||||
|
||||||
editorState.updateSelectionWithReason( | ||||||
Selection.single( | ||||||
path: node.path, | ||||||
startOffset: node.delta?.length ?? 0, | ||||||
), | ||||||
reason: SelectionUpdateReason.uiEvent, | ||||||
); | ||||||
}, | ||||||
child: Container( | ||||||
margin: EdgeInsets.only(top: node.verticalSpacing), | ||||||
padding: EdgeInsets.only(left: node.leftIndent), | ||||||
child: Text( | ||||||
node.outlineItemText, | ||||||
style: editorState.editorStyle.textStyleConfiguration.text, | ||||||
), | ||||||
), | ||||||
), | ||||||
); | ||||||
} | ||||||
} | ||||||
|
||||||
extension on Node { | ||||||
double get verticalSpacing { | ||||||
if (type != HeadingBlockKeys.type) { | ||||||
assert(false); | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just noticed this assert, feels like a hack to me. There are two similar ones further below. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. because only node with heading type can fetch this values. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, but Hence why I feel that it is a hack. It would make more sense to have There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed. |
||||||
return 0.0; | ||||||
} | ||||||
final level = attributes[HeadingBlockKeys.level]; | ||||||
if (level == 1) { | ||||||
return 10; | ||||||
} else if (level == 2) { | ||||||
return 8; | ||||||
} | ||||||
return 5; | ||||||
} | ||||||
|
||||||
double get leftIndent { | ||||||
if (type != HeadingBlockKeys.type) { | ||||||
assert(false); | ||||||
return 0.0; | ||||||
} | ||||||
final level = attributes[HeadingBlockKeys.level]; | ||||||
if (level == 2) { | ||||||
return 15; | ||||||
} else if (level == 3) { | ||||||
return 60; | ||||||
} | ||||||
return 0; | ||||||
} | ||||||
|
||||||
String get outlineItemText { | ||||||
if (type != HeadingBlockKeys.type) { | ||||||
assert(false); | ||||||
return ''; | ||||||
} | ||||||
final delta = this.delta; | ||||||
if (delta == null) { | ||||||
return ''; | ||||||
} | ||||||
final text = delta.toPlainText(); | ||||||
final level = attributes[HeadingBlockKeys.level]; | ||||||
if (level == 2) { | ||||||
return '∘ $text'; | ||||||
} else if (level == 3) { | ||||||
return '- $text'; | ||||||
} | ||||||
return text; | ||||||
} | ||||||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.