580 lines
18 KiB
Dart
580 lines
18 KiB
Dart
//import 'package:example/logging.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:invoiceninja_flutter/utils/markdown.dart';
|
|
import 'package:super_editor/super_editor.dart';
|
|
//import 'package:super_editor_markdown/super_editor_markdown.dart';
|
|
|
|
import 'package:invoiceninja_flutter/utils/super_editor/toolbar.dart';
|
|
//import '_toolbar.dart';
|
|
|
|
/// Example of a rich text editor.
|
|
///
|
|
/// This editor will expand in functionality as package
|
|
/// capabilities expand.
|
|
class ExampleEditor extends StatefulWidget {
|
|
const ExampleEditor({
|
|
Key? key,
|
|
required this.value,
|
|
this.onChanged,
|
|
}) : super(key: key);
|
|
|
|
final String value;
|
|
final Function(String)? onChanged;
|
|
|
|
@override
|
|
State<ExampleEditor> createState() => _ExampleEditorState();
|
|
}
|
|
|
|
class _ExampleEditorState extends State<ExampleEditor> {
|
|
final GlobalKey _viewportKey = GlobalKey();
|
|
final GlobalKey _docLayoutKey = GlobalKey();
|
|
|
|
late MutableDocument _doc;
|
|
final _docChangeSignal = SignalNotifier();
|
|
late MutableDocumentComposer _composer;
|
|
late Editor _docEditor;
|
|
late CommonEditorOperations _docOps;
|
|
|
|
late FocusNode _editorFocusNode;
|
|
|
|
late ScrollController _scrollController;
|
|
|
|
final SelectionLayerLinks _selectionLayerLinks = SelectionLayerLinks();
|
|
|
|
final _darkBackground = const Color(0xFF222222);
|
|
final _lightBackground = Colors.white;
|
|
final _brightness = ValueNotifier<Brightness>(Brightness.light);
|
|
|
|
SuperEditorDebugVisualsConfig? _debugConfig;
|
|
|
|
final _textFormatBarOverlayController = OverlayPortalController();
|
|
final _textSelectionAnchor = ValueNotifier<Offset?>(null);
|
|
|
|
final _imageFormatBarOverlayController = OverlayPortalController();
|
|
final _imageSelectionAnchor = ValueNotifier<Offset?>(null);
|
|
|
|
// TODO: get rid of overlay controller once Android is refactored to use a control scope (as follow up to: https://github.com/superlistapp/super_editor/pull/1470)
|
|
final _overlayController = MagnifierAndToolbarController() //
|
|
..screenPadding = const EdgeInsets.all(20.0);
|
|
|
|
late final SuperEditorIosControlsController _iosControlsController;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
// Fix for <p> tags cutting off text
|
|
var markdown = widget.value;
|
|
markdown = markdown.replaceAll('<p/>', '\n');
|
|
markdown = markdown.replaceAll('<p>', '\n');
|
|
markdown = markdown.replaceAll('<div>', '\n');
|
|
markdown = markdown.replaceAll('</p>', '');
|
|
markdown = markdown.replaceAll('</div>', '');
|
|
|
|
// _doc = createInitialDocument()..addListener(_onDocumentChange);
|
|
_doc = deserializeMarkdownToDocument(markdown)
|
|
..addListener(_onDocumentChange);
|
|
_composer = MutableDocumentComposer();
|
|
_composer.selectionNotifier.addListener(_hideOrShowToolbar);
|
|
_docEditor =
|
|
createDefaultDocumentEditor(document: _doc, composer: _composer);
|
|
_docOps = CommonEditorOperations(
|
|
editor: _docEditor,
|
|
document: _doc,
|
|
composer: _composer,
|
|
documentLayoutResolver: () =>
|
|
_docLayoutKey.currentState as DocumentLayout,
|
|
);
|
|
_editorFocusNode = FocusNode();
|
|
_scrollController = ScrollController()..addListener(_hideOrShowToolbar);
|
|
|
|
_iosControlsController = SuperEditorIosControlsController();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_iosControlsController.dispose();
|
|
_scrollController.dispose();
|
|
_editorFocusNode.dispose();
|
|
_composer.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _onDocumentChange(_) {
|
|
_hideOrShowToolbar();
|
|
_docChangeSignal.notifyListeners();
|
|
}
|
|
|
|
void _hideOrShowToolbar() {
|
|
if (_gestureMode != DocumentGestureMode.mouse) {
|
|
// We only add our own toolbar when using mouse. On mobile, a bar
|
|
// is rendered for us.
|
|
return;
|
|
}
|
|
|
|
final selection = _composer.selection;
|
|
if (selection == null) {
|
|
// Nothing is selected. We don't want to show a toolbar
|
|
// in this case.
|
|
_hideEditorToolbar();
|
|
|
|
return;
|
|
}
|
|
if (selection.base.nodeId != selection.extent.nodeId) {
|
|
// More than one node is selected. We don't want to show
|
|
// a toolbar in this case.
|
|
_hideEditorToolbar();
|
|
_hideImageToolbar();
|
|
|
|
return;
|
|
}
|
|
if (selection.isCollapsed) {
|
|
// We only want to show the toolbar when a span of text
|
|
// is selected. Therefore, we ignore collapsed selections.
|
|
_hideEditorToolbar();
|
|
_hideImageToolbar();
|
|
|
|
return;
|
|
}
|
|
|
|
final selectedNode = _doc.getNodeById(selection.extent.nodeId);
|
|
|
|
if (selectedNode is ImageNode) {
|
|
//appLog.fine("Showing image toolbar");
|
|
// Show the editor's toolbar for image sizing.
|
|
_showImageToolbar();
|
|
_hideEditorToolbar();
|
|
return;
|
|
} else {
|
|
// The currently selected content is not an image. We don't
|
|
// want to show the image toolbar.
|
|
_hideImageToolbar();
|
|
}
|
|
|
|
if (selectedNode is TextNode) {
|
|
// Show the editor's toolbar for text styling.
|
|
_showEditorToolbar();
|
|
_hideImageToolbar();
|
|
return;
|
|
} else {
|
|
// The currently selected content is not a paragraph. We don't
|
|
// want to show a toolbar in this case.
|
|
_hideEditorToolbar();
|
|
}
|
|
}
|
|
|
|
void _showEditorToolbar() {
|
|
_textFormatBarOverlayController.show();
|
|
|
|
// Schedule a callback after this frame to locate the selection
|
|
// bounds on the screen and display the toolbar near the selected
|
|
// text.
|
|
// TODO: switch this to use a Leader and Follower
|
|
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
|
|
final docBoundingBox = (_docLayoutKey.currentState as DocumentLayout)
|
|
.getRectForSelection(
|
|
_composer.selection!.base, _composer.selection!.extent)!;
|
|
final docBox =
|
|
_docLayoutKey.currentContext!.findRenderObject() as RenderBox;
|
|
final overlayBoundingBox = Rect.fromPoints(
|
|
docBox.localToGlobal(docBoundingBox.topLeft),
|
|
docBox.localToGlobal(docBoundingBox.bottomRight),
|
|
);
|
|
|
|
_textSelectionAnchor.value = overlayBoundingBox.topCenter;
|
|
});
|
|
}
|
|
|
|
void _hideEditorToolbar() {
|
|
// Null out the selection anchor so that when it re-appears,
|
|
// the bar doesn't momentarily "flash" at its old anchor position.
|
|
_textSelectionAnchor.value = null;
|
|
|
|
_textFormatBarOverlayController.hide();
|
|
|
|
// Ensure that focus returns to the editor.
|
|
//
|
|
// I tried explicitly unfocus()'ing the URL textfield
|
|
// in the toolbar but it didn't return focus to the
|
|
// editor. I'm not sure why.
|
|
_editorFocusNode.requestFocus();
|
|
}
|
|
|
|
DocumentGestureMode get _gestureMode {
|
|
switch (defaultTargetPlatform) {
|
|
case TargetPlatform.android:
|
|
return DocumentGestureMode.android;
|
|
case TargetPlatform.iOS:
|
|
return DocumentGestureMode.iOS;
|
|
case TargetPlatform.fuchsia:
|
|
case TargetPlatform.linux:
|
|
case TargetPlatform.macOS:
|
|
case TargetPlatform.windows:
|
|
return DocumentGestureMode.mouse;
|
|
}
|
|
}
|
|
|
|
bool get _isMobile => _gestureMode != DocumentGestureMode.mouse;
|
|
|
|
TextInputSource get _inputSource {
|
|
switch (defaultTargetPlatform) {
|
|
case TargetPlatform.android:
|
|
case TargetPlatform.iOS:
|
|
case TargetPlatform.fuchsia:
|
|
case TargetPlatform.linux:
|
|
case TargetPlatform.macOS:
|
|
case TargetPlatform.windows:
|
|
return TextInputSource.ime;
|
|
}
|
|
}
|
|
|
|
void _cut() {
|
|
_docOps.cut();
|
|
// TODO: get rid of overlay controller once Android is refactored to use a control scope (as follow up to: https://github.com/superlistapp/super_editor/pull/1470)
|
|
_overlayController.hideToolbar();
|
|
_iosControlsController.hideToolbar();
|
|
}
|
|
|
|
void _copy() {
|
|
_docOps.copy();
|
|
// TODO: get rid of overlay controller once Android is refactored to use a control scope (as follow up to: https://github.com/superlistapp/super_editor/pull/1470)
|
|
_overlayController.hideToolbar();
|
|
_iosControlsController.hideToolbar();
|
|
}
|
|
|
|
void _paste() {
|
|
_docOps.paste();
|
|
// TODO: get rid of overlay controller once Android is refactored to use a control scope (as follow up to: https://github.com/superlistapp/super_editor/pull/1470)
|
|
_overlayController.hideToolbar();
|
|
_iosControlsController.hideToolbar();
|
|
}
|
|
|
|
void _selectAll() => _docOps.selectAll();
|
|
|
|
void _showImageToolbar() {
|
|
// Schedule a callback after this frame to locate the selection
|
|
// bounds on the screen and display the toolbar near the selected
|
|
// text.
|
|
// TODO: switch to a Leader and Follower for this
|
|
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
|
|
final docBoundingBox = (_docLayoutKey.currentState as DocumentLayout)
|
|
.getRectForSelection(
|
|
_composer.selection!.base, _composer.selection!.extent)!;
|
|
final docBox =
|
|
_docLayoutKey.currentContext!.findRenderObject() as RenderBox;
|
|
final overlayBoundingBox = Rect.fromPoints(
|
|
docBox.localToGlobal(docBoundingBox.topLeft),
|
|
docBox.localToGlobal(docBoundingBox.bottomRight),
|
|
);
|
|
|
|
_imageSelectionAnchor.value = overlayBoundingBox.center;
|
|
});
|
|
|
|
_imageFormatBarOverlayController.show();
|
|
}
|
|
|
|
void _hideImageToolbar() {
|
|
// Null out the selection anchor so that when the bar re-appears,
|
|
// it doesn't momentarily "flash" at its old anchor position.
|
|
_imageSelectionAnchor.value = null;
|
|
|
|
_imageFormatBarOverlayController.hide();
|
|
|
|
// Ensure that focus returns to the editor.
|
|
_editorFocusNode.requestFocus();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ValueListenableBuilder(
|
|
valueListenable: _brightness,
|
|
builder: (context, brightness, child) {
|
|
return Theme(
|
|
data: ThemeData(brightness: brightness),
|
|
child: child!,
|
|
);
|
|
},
|
|
child: Builder(
|
|
// This builder captures the new theme
|
|
builder: (themedContext) {
|
|
return OverlayPortal(
|
|
controller: _textFormatBarOverlayController,
|
|
overlayChildBuilder: _buildFloatingToolbar,
|
|
child: OverlayPortal(
|
|
controller: _imageFormatBarOverlayController,
|
|
overlayChildBuilder: _buildImageToolbar,
|
|
child: Stack(
|
|
children: [
|
|
Column(
|
|
children: [
|
|
Expanded(
|
|
child: _buildEditor(themedContext),
|
|
),
|
|
if (_isMobile) //
|
|
_buildMountedToolbar(),
|
|
],
|
|
),
|
|
/*
|
|
Align(
|
|
alignment: Alignment.bottomRight,
|
|
child: ListenableBuilder(
|
|
listenable: _composer.selectionNotifier,
|
|
builder: (context, child) {
|
|
return Padding(
|
|
padding: EdgeInsets.only(
|
|
bottom: _isMobile && _composer.selection != null
|
|
? 48
|
|
: 0),
|
|
child: child,
|
|
);
|
|
},
|
|
child: _buildCornerFabs(),
|
|
),
|
|
),
|
|
*/
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
/*
|
|
Widget _buildCornerFabs() {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(right: 16, bottom: 16),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
_buildDebugVisualsToggle(),
|
|
const SizedBox(height: 16),
|
|
_buildLightAndDarkModeToggle(),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildDebugVisualsToggle() {
|
|
return FloatingActionButton(
|
|
backgroundColor: _brightness.value == Brightness.light
|
|
? _darkBackground
|
|
: _lightBackground,
|
|
foregroundColor: _brightness.value == Brightness.light
|
|
? _lightBackground
|
|
: _darkBackground,
|
|
elevation: 5,
|
|
onPressed: () {
|
|
setState(() {
|
|
_debugConfig = _debugConfig != null
|
|
? null
|
|
: const SuperEditorDebugVisualsConfig(
|
|
showFocus: true,
|
|
showImeConnection: true,
|
|
);
|
|
});
|
|
},
|
|
child: const Icon(
|
|
Icons.bug_report,
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildLightAndDarkModeToggle() {
|
|
return FloatingActionButton(
|
|
backgroundColor: _brightness.value == Brightness.light
|
|
? _darkBackground
|
|
: _lightBackground,
|
|
foregroundColor: _brightness.value == Brightness.light
|
|
? _lightBackground
|
|
: _darkBackground,
|
|
elevation: 5,
|
|
onPressed: () {
|
|
_brightness.value = _brightness.value == Brightness.light
|
|
? Brightness.dark
|
|
: Brightness.light;
|
|
},
|
|
child: _brightness.value == Brightness.light
|
|
? const Icon(
|
|
Icons.dark_mode,
|
|
)
|
|
: const Icon(
|
|
Icons.light_mode,
|
|
),
|
|
);
|
|
}
|
|
*/
|
|
|
|
Widget _buildEditor(BuildContext context) {
|
|
final isLight = Theme.of(context).brightness == Brightness.light;
|
|
|
|
return ColoredBox(
|
|
color: isLight ? _lightBackground : _darkBackground,
|
|
child: SuperEditorDebugVisuals(
|
|
config: _debugConfig ?? const SuperEditorDebugVisualsConfig(),
|
|
child: KeyedSubtree(
|
|
key: _viewportKey,
|
|
child: SuperEditorIosControlsScope(
|
|
controller: _iosControlsController,
|
|
child: SuperEditor(
|
|
editor: _docEditor,
|
|
document: _doc,
|
|
composer: _composer,
|
|
focusNode: _editorFocusNode,
|
|
scrollController: _scrollController,
|
|
documentLayoutKey: _docLayoutKey,
|
|
documentOverlayBuilders: [
|
|
DefaultCaretOverlayBuilder(
|
|
caretStyle: const CaretStyle().copyWith(
|
|
color: isLight ? Colors.black : Colors.redAccent),
|
|
),
|
|
if (defaultTargetPlatform == TargetPlatform.iOS) ...[
|
|
SuperEditorAndroidToolbarFocalPointDocumentLayerBuilder(),
|
|
SuperEditorAndroidHandlesDocumentLayerBuilder(),
|
|
],
|
|
if (defaultTargetPlatform == TargetPlatform.android) ...[
|
|
SuperEditorAndroidToolbarFocalPointDocumentLayerBuilder(),
|
|
SuperEditorAndroidHandlesDocumentLayerBuilder(),
|
|
],
|
|
],
|
|
selectionLayerLinks: _selectionLayerLinks,
|
|
selectionStyle: isLight
|
|
? defaultSelectionStyle
|
|
: SelectionStyles(
|
|
selectionColor: Colors.red.withOpacity(0.3),
|
|
),
|
|
stylesheet: defaultStylesheet.copyWith(
|
|
addRulesAfter: [
|
|
if (!isLight) ..._darkModeStyles,
|
|
taskStyles,
|
|
],
|
|
),
|
|
componentBuilders: [
|
|
TaskComponentBuilder(_docEditor),
|
|
...defaultComponentBuilders,
|
|
],
|
|
gestureMode: _gestureMode,
|
|
inputSource: _inputSource,
|
|
keyboardActions: _inputSource == TextInputSource.ime
|
|
? defaultImeKeyboardActions
|
|
: defaultKeyboardActions,
|
|
androidToolbarBuilder: (_) => _buildAndroidFloatingToolbar(),
|
|
overlayController: _overlayController,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildAndroidFloatingToolbar() {
|
|
return ListenableBuilder(
|
|
listenable: _brightness,
|
|
builder: (context, _) {
|
|
return Theme(
|
|
data: ThemeData(brightness: _brightness.value),
|
|
child: AndroidTextEditingFloatingToolbar(
|
|
onCutPressed: _cut,
|
|
onCopyPressed: _copy,
|
|
onPastePressed: _paste,
|
|
onSelectAllPressed: _selectAll,
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildMountedToolbar() {
|
|
return MultiListenableBuilder(
|
|
listenables: <Listenable>{
|
|
_docChangeSignal,
|
|
_composer.selectionNotifier,
|
|
},
|
|
builder: (_) {
|
|
final selection = _composer.selection;
|
|
|
|
if (selection == null) {
|
|
return const SizedBox();
|
|
}
|
|
|
|
return KeyboardEditingToolbar(
|
|
editor: _docEditor,
|
|
document: _doc,
|
|
composer: _composer,
|
|
commonOps: _docOps,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildFloatingToolbar(BuildContext context) {
|
|
return EditorToolbar(
|
|
editorViewportKey: _viewportKey,
|
|
anchor: _selectionLayerLinks.expandedSelectionBoundsLink,
|
|
editorFocusNode: _editorFocusNode,
|
|
editor: _docEditor,
|
|
document: _doc,
|
|
composer: _composer,
|
|
closeToolbar: _hideEditorToolbar,
|
|
);
|
|
}
|
|
|
|
Widget _buildImageToolbar(BuildContext context) {
|
|
return ImageFormatToolbar(
|
|
anchor: _imageSelectionAnchor,
|
|
composer: _composer,
|
|
setWidth: (nodeId, width) {
|
|
print('Applying width $width to node $nodeId');
|
|
final node = _doc.getNodeById(nodeId)!;
|
|
final currentStyles =
|
|
SingleColumnLayoutComponentStyles.fromMetadata(node);
|
|
SingleColumnLayoutComponentStyles(
|
|
width: width,
|
|
padding: currentStyles.padding,
|
|
).applyTo(node);
|
|
|
|
// TODO: schedule a presentation reflow so that the image changes size immediately (https://github.com/superlistapp/super_editor/issues/1529)
|
|
// Right now, nothing happens when pressing the button, unless we force a
|
|
// rebuild/reflow.
|
|
},
|
|
closeToolbar: _hideImageToolbar,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Makes text light, for use during dark mode styling.
|
|
final _darkModeStyles = [
|
|
StyleRule(
|
|
BlockSelector.all,
|
|
(doc, docNode) {
|
|
return {
|
|
'textStyle': const TextStyle(
|
|
color: Color(0xFFCCCCCC),
|
|
),
|
|
};
|
|
},
|
|
),
|
|
StyleRule(
|
|
const BlockSelector('header1'),
|
|
(doc, docNode) {
|
|
return {
|
|
'textStyle': const TextStyle(
|
|
color: Color(0xFF888888),
|
|
),
|
|
};
|
|
},
|
|
),
|
|
StyleRule(
|
|
const BlockSelector('header2'),
|
|
(doc, docNode) {
|
|
return {
|
|
'textStyle': const TextStyle(
|
|
color: Color(0xFF888888),
|
|
),
|
|
};
|
|
},
|
|
),
|
|
];
|