commit
3ca7b3237d
70 changed files with 4306 additions and 1718 deletions
@ -0,0 +1,15 @@ |
|||||||
|
import 'package:flutter/material.dart'; |
||||||
|
|
||||||
|
class QuillDialogTheme { |
||||||
|
QuillDialogTheme( |
||||||
|
{this.labelTextStyle, this.inputTextStyle, this.dialogBackgroundColor}); |
||||||
|
|
||||||
|
///The text style to use for the label shown in the link-input dialog |
||||||
|
final TextStyle? labelTextStyle; |
||||||
|
|
||||||
|
///The text style to use for the input text shown in the link-input dialog |
||||||
|
final TextStyle? inputTextStyle; |
||||||
|
|
||||||
|
///The background color for the [LinkDialog()] |
||||||
|
final Color? dialogBackgroundColor; |
||||||
|
} |
@ -0,0 +1,30 @@ |
|||||||
|
import 'package:flutter/material.dart'; |
||||||
|
|
||||||
|
class QuillIconTheme { |
||||||
|
const QuillIconTheme({ |
||||||
|
this.iconSelectedColor, |
||||||
|
this.iconUnselectedColor, |
||||||
|
this.iconSelectedFillColor, |
||||||
|
this.iconUnselectedFillColor, |
||||||
|
this.disabledIconColor, |
||||||
|
this.disabledIconFillColor, |
||||||
|
}); |
||||||
|
|
||||||
|
///The color to use for selected icons in the toolbar |
||||||
|
final Color? iconSelectedColor; |
||||||
|
|
||||||
|
///The color to use for unselected icons in the toolbar |
||||||
|
final Color? iconUnselectedColor; |
||||||
|
|
||||||
|
///The fill color to use for the selected icons in the toolbar |
||||||
|
final Color? iconSelectedFillColor; |
||||||
|
|
||||||
|
///The fill color to use for the unselected icons in the toolbar |
||||||
|
final Color? iconUnselectedFillColor; |
||||||
|
|
||||||
|
///The color to use for disabled icons in the toolbar |
||||||
|
final Color? disabledIconColor; |
||||||
|
|
||||||
|
///The fill color to use for disabled icons in the toolbar |
||||||
|
final Color? disabledIconFillColor; |
||||||
|
} |
@ -0,0 +1,110 @@ |
|||||||
|
import 'package:i18n_extension/i18n_extension.dart'; |
||||||
|
|
||||||
|
extension Localization on String { |
||||||
|
static final _t = Translations.byLocale('en') + |
||||||
|
{ |
||||||
|
'en': { |
||||||
|
'Paste a link': 'Paste a link', |
||||||
|
'Ok': 'Ok', |
||||||
|
'Select Color': 'Select Color', |
||||||
|
'Gallery': 'Gallery', |
||||||
|
'Link': 'Link', |
||||||
|
'Please first select some text to transform into a link.': |
||||||
|
'Please first select some text to transform into a link.', |
||||||
|
}, |
||||||
|
'ar': { |
||||||
|
'Paste a link': 'نسخ الرابط', |
||||||
|
'Ok': 'نعم', |
||||||
|
'Select Color': 'اختار اللون', |
||||||
|
'Gallery': 'الصور', |
||||||
|
'Link': 'الرابط', |
||||||
|
'Please first select some text to transform into a link.': |
||||||
|
'يرجى اختيار نص للتحويل إلى رابط', |
||||||
|
}, |
||||||
|
'da': { |
||||||
|
'Paste a link': 'Indsæt link', |
||||||
|
'Ok': 'Ok', |
||||||
|
'Select Color': 'Vælg farve', |
||||||
|
'Gallery': 'Galleri', |
||||||
|
'Link': 'Link', |
||||||
|
'Please first select some text to transform into a link.': |
||||||
|
'Vælg venligst først noget tekst for at lave det om til et link.', |
||||||
|
}, |
||||||
|
'de': { |
||||||
|
'Paste a link': 'Link hinzufügen', |
||||||
|
'Ok': 'Ok', |
||||||
|
'Select Color': 'Farbe auswählen', |
||||||
|
'Gallery': 'Gallerie', |
||||||
|
'Link': 'Link', |
||||||
|
'Please first select some text to transform into a link.': |
||||||
|
'Markiere bitte zuerst einen Text, um diesen in einen Link zu ' |
||||||
|
'verwandeln.', |
||||||
|
}, |
||||||
|
'fr': { |
||||||
|
'Paste a link': 'Coller un lien', |
||||||
|
'Ok': 'Ok', |
||||||
|
'Select Color': 'Choisir une couleur', |
||||||
|
'Gallery': 'Galerie', |
||||||
|
'Link': 'Lien', |
||||||
|
'Please first select some text to transform into a link.': |
||||||
|
"Veuillez d'abord sélectionner un texte à transformer en lien.", |
||||||
|
}, |
||||||
|
'zh_CN': { |
||||||
|
'Paste a link': '粘贴链接', |
||||||
|
'Ok': '好', |
||||||
|
'Select Color': '选择颜色', |
||||||
|
'Gallery': '相簿', |
||||||
|
'Link': '链接', |
||||||
|
'Please first select some text to transform into a link.': |
||||||
|
'请先选择一些要转化为链接的文本', |
||||||
|
}, |
||||||
|
'ko': { |
||||||
|
'Paste a link': '링크를 붙여넣어 주세요.', |
||||||
|
'Ok': '확인', |
||||||
|
'Select Color': '색상 선택', |
||||||
|
'Gallery': '갤러리', |
||||||
|
'Link': '링크', |
||||||
|
'Please first select some text to transform into a link.': |
||||||
|
'링크로 전환할 글자를 먼저 선택해주세요.', |
||||||
|
}, |
||||||
|
'ru': { |
||||||
|
'Paste a link': 'Вставить ссылку', |
||||||
|
'Ok': 'ОК', |
||||||
|
'Select Color': 'Выбрать цвет', |
||||||
|
'Gallery': 'Галерея', |
||||||
|
'Link': 'Ссылка', |
||||||
|
'Please first select some text to transform into a link.': |
||||||
|
'Выделите часть текста для создания ссылки.', |
||||||
|
}, |
||||||
|
'es': { |
||||||
|
'Paste a link': 'Pega un enlace', |
||||||
|
'Ok': 'Ok', |
||||||
|
'Select Color': 'Selecciona un color', |
||||||
|
'Gallery': 'Galeria', |
||||||
|
'Link': 'Enlace', |
||||||
|
'Please first select some text to transform into a link.': |
||||||
|
'Por favor selecciona primero un texto para transformarlo ' |
||||||
|
'en un enlace', |
||||||
|
}, |
||||||
|
'tr': { |
||||||
|
'Paste a link': 'Bağlantıyı Yapıştır', |
||||||
|
'Ok': 'Tamam', |
||||||
|
'Select Color': 'Renk Seçin', |
||||||
|
'Gallery': 'Galeri', |
||||||
|
'Link': 'Bağlantı', |
||||||
|
'Please first select some text to transform into a link.': |
||||||
|
'Lütfen bağlantıya dönüştürmek için bir metin seçin.', |
||||||
|
}, |
||||||
|
'uk': { |
||||||
|
'Paste a link': 'Вставити посилання', |
||||||
|
'Ok': 'ОК', |
||||||
|
'Select Color': 'Вибрати колір', |
||||||
|
'Gallery': 'Галерея', |
||||||
|
'Link': 'Посилання', |
||||||
|
'Please first select some text to transform into a link.': |
||||||
|
'Виділіть текст для створення посилання.', |
||||||
|
}, |
||||||
|
}; |
||||||
|
|
||||||
|
String get i18n => localize(this, _t); |
||||||
|
} |
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,31 @@ |
|||||||
|
// The corner radius of the floating cursor in pixels. |
||||||
|
import 'dart:ui'; |
||||||
|
|
||||||
|
import '../../widgets/cursor.dart'; |
||||||
|
|
||||||
|
const Radius _kFloatingCaretRadius = Radius.circular(1); |
||||||
|
|
||||||
|
/// Floating painter responsible for painting the floating cursor when |
||||||
|
/// floating mode is activated |
||||||
|
class FloatingCursorPainter { |
||||||
|
FloatingCursorPainter({ |
||||||
|
required this.floatingCursorRect, |
||||||
|
required this.style, |
||||||
|
}); |
||||||
|
|
||||||
|
CursorStyle style; |
||||||
|
|
||||||
|
Rect? floatingCursorRect; |
||||||
|
|
||||||
|
final Paint floatingCursorPaint = Paint(); |
||||||
|
|
||||||
|
void paint(Canvas canvas) { |
||||||
|
final floatingCursorRect = this.floatingCursorRect; |
||||||
|
final floatingCursorColor = style.color.withOpacity(0.75); |
||||||
|
if (floatingCursorRect == null) return; |
||||||
|
canvas.drawRRect( |
||||||
|
RRect.fromRectAndRadius(floatingCursorRect, _kFloatingCaretRadius), |
||||||
|
floatingCursorPaint..color = floatingCursorColor, |
||||||
|
); |
||||||
|
} |
||||||
|
} |
@ -1,106 +1,89 @@ |
|||||||
import 'package:flutter/foundation.dart'; |
import 'package:flutter/material.dart'; |
||||||
import 'package:flutter/services.dart'; |
import 'package:flutter/services.dart'; |
||||||
import 'package:flutter/widgets.dart'; |
|
||||||
|
|
||||||
enum InputShortcut { CUT, COPY, PASTE, SELECT_ALL } |
|
||||||
|
|
||||||
typedef CursorMoveCallback = void Function( |
|
||||||
LogicalKeyboardKey key, bool wordModifier, bool lineModifier, bool shift); |
|
||||||
typedef InputShortcutCallback = void Function(InputShortcut? shortcut); |
|
||||||
typedef OnDeleteCallback = void Function(bool forward); |
|
||||||
|
|
||||||
class KeyboardListener { |
|
||||||
KeyboardListener(this.onCursorMove, this.onShortcut, this.onDelete); |
|
||||||
|
|
||||||
final CursorMoveCallback onCursorMove; |
|
||||||
final InputShortcutCallback onShortcut; |
|
||||||
final OnDeleteCallback onDelete; |
|
||||||
|
|
||||||
static final Set<LogicalKeyboardKey> _moveKeys = <LogicalKeyboardKey>{ |
|
||||||
LogicalKeyboardKey.arrowRight, |
|
||||||
LogicalKeyboardKey.arrowLeft, |
|
||||||
LogicalKeyboardKey.arrowUp, |
|
||||||
LogicalKeyboardKey.arrowDown, |
|
||||||
}; |
|
||||||
|
|
||||||
static final Set<LogicalKeyboardKey> _shortcutKeys = <LogicalKeyboardKey>{ |
|
||||||
LogicalKeyboardKey.keyA, |
|
||||||
LogicalKeyboardKey.keyC, |
|
||||||
LogicalKeyboardKey.keyV, |
|
||||||
LogicalKeyboardKey.keyX, |
|
||||||
LogicalKeyboardKey.delete, |
|
||||||
LogicalKeyboardKey.backspace, |
|
||||||
}; |
|
||||||
|
|
||||||
static final Set<LogicalKeyboardKey> _nonModifierKeys = <LogicalKeyboardKey>{ |
|
||||||
..._shortcutKeys, |
|
||||||
..._moveKeys, |
|
||||||
}; |
|
||||||
|
|
||||||
static final Set<LogicalKeyboardKey> _modifierKeys = <LogicalKeyboardKey>{ |
|
||||||
LogicalKeyboardKey.shift, |
|
||||||
LogicalKeyboardKey.control, |
|
||||||
LogicalKeyboardKey.alt, |
|
||||||
}; |
|
||||||
|
|
||||||
static final Set<LogicalKeyboardKey> _macOsModifierKeys = |
|
||||||
<LogicalKeyboardKey>{ |
|
||||||
LogicalKeyboardKey.shift, |
|
||||||
LogicalKeyboardKey.meta, |
|
||||||
LogicalKeyboardKey.alt, |
|
||||||
}; |
|
||||||
|
|
||||||
static final Set<LogicalKeyboardKey> _interestingKeys = <LogicalKeyboardKey>{ |
|
||||||
..._modifierKeys, |
|
||||||
..._macOsModifierKeys, |
|
||||||
..._nonModifierKeys, |
|
||||||
}; |
|
||||||
|
|
||||||
static final Map<LogicalKeyboardKey, InputShortcut> _keyToShortcut = { |
|
||||||
LogicalKeyboardKey.keyX: InputShortcut.CUT, |
|
||||||
LogicalKeyboardKey.keyC: InputShortcut.COPY, |
|
||||||
LogicalKeyboardKey.keyV: InputShortcut.PASTE, |
|
||||||
LogicalKeyboardKey.keyA: InputShortcut.SELECT_ALL, |
|
||||||
}; |
|
||||||
|
|
||||||
KeyEventResult handleRawKeyEvent(RawKeyEvent event) { |
|
||||||
if (kIsWeb) { |
|
||||||
// On web platform, we ignore the key because it's already processed. |
|
||||||
return KeyEventResult.ignored; |
|
||||||
} |
|
||||||
|
|
||||||
if (event is! RawKeyDownEvent) { |
class QuillPressedKeys extends ChangeNotifier { |
||||||
return KeyEventResult.ignored; |
static QuillPressedKeys of(BuildContext context) { |
||||||
} |
final widget = |
||||||
|
context.dependOnInheritedWidgetOfExactType<_QuillPressedKeysAccess>(); |
||||||
|
return widget!.pressedKeys; |
||||||
|
} |
||||||
|
|
||||||
final keysPressed = |
bool _metaPressed = false; |
||||||
LogicalKeyboardKey.collapseSynonyms(RawKeyboard.instance.keysPressed); |
bool _controlPressed = false; |
||||||
final key = event.logicalKey; |
|
||||||
final isMacOS = event.data is RawKeyEventDataMacOs; |
|
||||||
if (!_nonModifierKeys.contains(key) || |
|
||||||
keysPressed |
|
||||||
.difference(isMacOS ? _macOsModifierKeys : _modifierKeys) |
|
||||||
.length > |
|
||||||
1 || |
|
||||||
keysPressed.difference(_interestingKeys).isNotEmpty) { |
|
||||||
return KeyEventResult.ignored; |
|
||||||
} |
|
||||||
|
|
||||||
if (_moveKeys.contains(key)) { |
/// Whether meta key is currently pressed. |
||||||
onCursorMove( |
bool get metaPressed => _metaPressed; |
||||||
key, |
|
||||||
isMacOS ? event.isAltPressed : event.isControlPressed, |
/// Whether control key is currently pressed. |
||||||
isMacOS ? event.isMetaPressed : event.isAltPressed, |
bool get controlPressed => _controlPressed; |
||||||
event.isShiftPressed); |
|
||||||
} else if (isMacOS |
void _updatePressedKeys(Set<LogicalKeyboardKey> pressedKeys) { |
||||||
? event.isMetaPressed |
final meta = pressedKeys.contains(LogicalKeyboardKey.metaLeft) || |
||||||
: event.isControlPressed && _shortcutKeys.contains(key)) { |
pressedKeys.contains(LogicalKeyboardKey.metaRight); |
||||||
onShortcut(_keyToShortcut[key]); |
final control = pressedKeys.contains(LogicalKeyboardKey.controlLeft) || |
||||||
} else if (key == LogicalKeyboardKey.delete) { |
pressedKeys.contains(LogicalKeyboardKey.controlRight); |
||||||
onDelete(true); |
if (_metaPressed != meta || _controlPressed != control) { |
||||||
} else if (key == LogicalKeyboardKey.backspace) { |
_metaPressed = meta; |
||||||
onDelete(false); |
_controlPressed = control; |
||||||
|
notifyListeners(); |
||||||
} |
} |
||||||
return KeyEventResult.ignored; |
} |
||||||
|
} |
||||||
|
|
||||||
|
class QuillKeyboardListener extends StatefulWidget { |
||||||
|
const QuillKeyboardListener({required this.child, Key? key}) |
||||||
|
: super(key: key); |
||||||
|
|
||||||
|
final Widget child; |
||||||
|
|
||||||
|
@override |
||||||
|
QuillKeyboardListenerState createState() => QuillKeyboardListenerState(); |
||||||
|
} |
||||||
|
|
||||||
|
class QuillKeyboardListenerState extends State<QuillKeyboardListener> { |
||||||
|
final QuillPressedKeys _pressedKeys = QuillPressedKeys(); |
||||||
|
|
||||||
|
bool _keyEvent(KeyEvent event) { |
||||||
|
_pressedKeys |
||||||
|
._updatePressedKeys(HardwareKeyboard.instance.logicalKeysPressed); |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
void initState() { |
||||||
|
super.initState(); |
||||||
|
HardwareKeyboard.instance.addHandler(_keyEvent); |
||||||
|
_pressedKeys |
||||||
|
._updatePressedKeys(HardwareKeyboard.instance.logicalKeysPressed); |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
void dispose() { |
||||||
|
HardwareKeyboard.instance.removeHandler(_keyEvent); |
||||||
|
_pressedKeys.dispose(); |
||||||
|
super.dispose(); |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
Widget build(BuildContext context) { |
||||||
|
return _QuillPressedKeysAccess( |
||||||
|
pressedKeys: _pressedKeys, |
||||||
|
child: widget.child, |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
class _QuillPressedKeysAccess extends InheritedWidget { |
||||||
|
const _QuillPressedKeysAccess({ |
||||||
|
required this.pressedKeys, |
||||||
|
required Widget child, |
||||||
|
Key? key, |
||||||
|
}) : super(key: key, child: child); |
||||||
|
|
||||||
|
final QuillPressedKeys pressedKeys; |
||||||
|
|
||||||
|
@override |
||||||
|
bool updateShouldNotify(covariant _QuillPressedKeysAccess oldWidget) { |
||||||
|
return oldWidget.pressedKeys != pressedKeys; |
||||||
} |
} |
||||||
} |
} |
||||||
|
@ -0,0 +1,170 @@ |
|||||||
|
import 'package:flutter/cupertino.dart'; |
||||||
|
import 'package:flutter/foundation.dart'; |
||||||
|
import 'package:flutter/material.dart'; |
||||||
|
|
||||||
|
import '../../models/documents/nodes/node.dart'; |
||||||
|
|
||||||
|
/// List of possible actions returned from [LinkActionPickerDelegate]. |
||||||
|
enum LinkMenuAction { |
||||||
|
/// Launch the link |
||||||
|
launch, |
||||||
|
|
||||||
|
/// Copy to clipboard |
||||||
|
copy, |
||||||
|
|
||||||
|
/// Remove link style attribute |
||||||
|
remove, |
||||||
|
|
||||||
|
/// No-op |
||||||
|
none, |
||||||
|
} |
||||||
|
|
||||||
|
/// Used internally by widget layer. |
||||||
|
typedef LinkActionPicker = Future<LinkMenuAction> Function(Node linkNode); |
||||||
|
|
||||||
|
typedef LinkActionPickerDelegate = Future<LinkMenuAction> Function( |
||||||
|
BuildContext context, String link); |
||||||
|
|
||||||
|
Future<LinkMenuAction> defaultLinkActionPickerDelegate( |
||||||
|
BuildContext context, String link) async { |
||||||
|
switch (defaultTargetPlatform) { |
||||||
|
case TargetPlatform.iOS: |
||||||
|
return _showCupertinoLinkMenu(context, link); |
||||||
|
case TargetPlatform.android: |
||||||
|
return _showMaterialMenu(context, link); |
||||||
|
default: |
||||||
|
assert( |
||||||
|
false, |
||||||
|
'defaultShowLinkActionsMenu not supposed to ' |
||||||
|
'be invoked for $defaultTargetPlatform'); |
||||||
|
return LinkMenuAction.none; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
Future<LinkMenuAction> _showCupertinoLinkMenu( |
||||||
|
BuildContext context, String link) async { |
||||||
|
final result = await showCupertinoModalPopup<LinkMenuAction>( |
||||||
|
context: context, |
||||||
|
builder: (ctx) { |
||||||
|
return CupertinoActionSheet( |
||||||
|
title: Text(link), |
||||||
|
actions: [ |
||||||
|
_CupertinoAction( |
||||||
|
title: 'Open', |
||||||
|
icon: Icons.language_sharp, |
||||||
|
onPressed: () => Navigator.of(context).pop(LinkMenuAction.launch), |
||||||
|
), |
||||||
|
_CupertinoAction( |
||||||
|
title: 'Copy', |
||||||
|
icon: Icons.copy_sharp, |
||||||
|
onPressed: () => Navigator.of(context).pop(LinkMenuAction.copy), |
||||||
|
), |
||||||
|
_CupertinoAction( |
||||||
|
title: 'Remove', |
||||||
|
icon: Icons.link_off_sharp, |
||||||
|
onPressed: () => Navigator.of(context).pop(LinkMenuAction.remove), |
||||||
|
), |
||||||
|
], |
||||||
|
); |
||||||
|
}, |
||||||
|
); |
||||||
|
return result ?? LinkMenuAction.none; |
||||||
|
} |
||||||
|
|
||||||
|
class _CupertinoAction extends StatelessWidget { |
||||||
|
const _CupertinoAction({ |
||||||
|
required this.title, |
||||||
|
required this.icon, |
||||||
|
required this.onPressed, |
||||||
|
Key? key, |
||||||
|
}) : super(key: key); |
||||||
|
|
||||||
|
final String title; |
||||||
|
final IconData icon; |
||||||
|
final VoidCallback onPressed; |
||||||
|
|
||||||
|
@override |
||||||
|
Widget build(BuildContext context) { |
||||||
|
final theme = Theme.of(context); |
||||||
|
return CupertinoActionSheetAction( |
||||||
|
onPressed: onPressed, |
||||||
|
child: Padding( |
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8), |
||||||
|
child: Row( |
||||||
|
children: [ |
||||||
|
Expanded( |
||||||
|
child: Text( |
||||||
|
title, |
||||||
|
textAlign: TextAlign.start, |
||||||
|
style: TextStyle(color: theme.colorScheme.onSurface), |
||||||
|
), |
||||||
|
), |
||||||
|
Icon( |
||||||
|
icon, |
||||||
|
size: theme.iconTheme.size, |
||||||
|
color: theme.colorScheme.onSurface.withOpacity(0.75), |
||||||
|
) |
||||||
|
], |
||||||
|
), |
||||||
|
), |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
Future<LinkMenuAction> _showMaterialMenu( |
||||||
|
BuildContext context, String link) async { |
||||||
|
final result = await showModalBottomSheet<LinkMenuAction>( |
||||||
|
context: context, |
||||||
|
builder: (ctx) { |
||||||
|
return Column( |
||||||
|
mainAxisSize: MainAxisSize.min, |
||||||
|
children: [ |
||||||
|
_MaterialAction( |
||||||
|
title: 'Open', |
||||||
|
icon: Icons.language_sharp, |
||||||
|
onPressed: () => Navigator.of(context).pop(LinkMenuAction.launch), |
||||||
|
), |
||||||
|
_MaterialAction( |
||||||
|
title: 'Copy', |
||||||
|
icon: Icons.copy_sharp, |
||||||
|
onPressed: () => Navigator.of(context).pop(LinkMenuAction.copy), |
||||||
|
), |
||||||
|
_MaterialAction( |
||||||
|
title: 'Remove', |
||||||
|
icon: Icons.link_off_sharp, |
||||||
|
onPressed: () => Navigator.of(context).pop(LinkMenuAction.remove), |
||||||
|
), |
||||||
|
], |
||||||
|
); |
||||||
|
}, |
||||||
|
); |
||||||
|
|
||||||
|
return result ?? LinkMenuAction.none; |
||||||
|
} |
||||||
|
|
||||||
|
class _MaterialAction extends StatelessWidget { |
||||||
|
const _MaterialAction({ |
||||||
|
required this.title, |
||||||
|
required this.icon, |
||||||
|
required this.onPressed, |
||||||
|
Key? key, |
||||||
|
}) : super(key: key); |
||||||
|
|
||||||
|
final String title; |
||||||
|
final IconData icon; |
||||||
|
final VoidCallback onPressed; |
||||||
|
|
||||||
|
@override |
||||||
|
Widget build(BuildContext context) { |
||||||
|
final theme = Theme.of(context); |
||||||
|
return ListTile( |
||||||
|
leading: Icon( |
||||||
|
icon, |
||||||
|
size: theme.iconTheme.size, |
||||||
|
color: theme.colorScheme.onSurface.withOpacity(0.75), |
||||||
|
), |
||||||
|
title: Text(title), |
||||||
|
onTap: onPressed, |
||||||
|
); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,354 @@ |
|||||||
|
import 'dart:math' as math; |
||||||
|
|
||||||
|
import 'package:flutter/material.dart'; |
||||||
|
import 'package:flutter/rendering.dart'; |
||||||
|
import 'package:flutter/widgets.dart'; |
||||||
|
|
||||||
|
/// Very similar to [SingleChildView] but with a [ViewportBuilder] argument |
||||||
|
/// instead of a [Widget] |
||||||
|
/// |
||||||
|
/// Useful when child needs [ViewportOffset] (e.g. [RenderEditor]) |
||||||
|
/// see: [SingleChildScrollView] |
||||||
|
class QuillSingleChildScrollView extends StatelessWidget { |
||||||
|
/// Creates a box in which a single widget can be scrolled. |
||||||
|
const QuillSingleChildScrollView({ |
||||||
|
required this.controller, |
||||||
|
required this.viewportBuilder, |
||||||
|
Key? key, |
||||||
|
this.physics, |
||||||
|
this.restorationId, |
||||||
|
}) : super(key: key); |
||||||
|
|
||||||
|
/// An object that can be used to control the position to which this scroll |
||||||
|
/// view is scrolled. |
||||||
|
/// |
||||||
|
/// Must be null if [primary] is true. |
||||||
|
/// |
||||||
|
/// A [ScrollController] serves several purposes. It can be used to control |
||||||
|
/// the initial scroll position (see [ScrollController.initialScrollOffset]). |
||||||
|
/// It can be used to control whether the scroll view should automatically |
||||||
|
/// save and restore its scroll position in the [PageStorage] (see |
||||||
|
/// [ScrollController.keepScrollOffset]). It can be used to read the current |
||||||
|
/// scroll position (see [ScrollController.offset]), or change it (see |
||||||
|
/// [ScrollController.animateTo]). |
||||||
|
final ScrollController controller; |
||||||
|
|
||||||
|
/// How the scroll view should respond to user input. |
||||||
|
/// |
||||||
|
/// For example, determines how the scroll view continues to animate after the |
||||||
|
/// user stops dragging the scroll view. |
||||||
|
/// |
||||||
|
/// Defaults to matching platform conventions. |
||||||
|
final ScrollPhysics? physics; |
||||||
|
|
||||||
|
/// {@macro flutter.widgets.scrollable.restorationId} |
||||||
|
final String? restorationId; |
||||||
|
|
||||||
|
final ViewportBuilder viewportBuilder; |
||||||
|
|
||||||
|
AxisDirection _getDirection(BuildContext context) { |
||||||
|
return getAxisDirectionFromAxisReverseAndDirectionality( |
||||||
|
context, Axis.vertical, false); |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
Widget build(BuildContext context) { |
||||||
|
final axisDirection = _getDirection(context); |
||||||
|
final scrollController = controller; |
||||||
|
final scrollable = Scrollable( |
||||||
|
axisDirection: axisDirection, |
||||||
|
controller: scrollController, |
||||||
|
physics: physics, |
||||||
|
restorationId: restorationId, |
||||||
|
viewportBuilder: (context, offset) { |
||||||
|
return _SingleChildViewport( |
||||||
|
offset: offset, |
||||||
|
child: viewportBuilder(context, offset), |
||||||
|
); |
||||||
|
}, |
||||||
|
); |
||||||
|
return scrollable; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
class _SingleChildViewport extends SingleChildRenderObjectWidget { |
||||||
|
const _SingleChildViewport({ |
||||||
|
required this.offset, |
||||||
|
Key? key, |
||||||
|
Widget? child, |
||||||
|
}) : super(key: key, child: child); |
||||||
|
|
||||||
|
final ViewportOffset offset; |
||||||
|
|
||||||
|
@override |
||||||
|
_RenderSingleChildViewport createRenderObject(BuildContext context) { |
||||||
|
return _RenderSingleChildViewport( |
||||||
|
offset: offset, |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
void updateRenderObject( |
||||||
|
BuildContext context, _RenderSingleChildViewport renderObject) { |
||||||
|
// Order dependency: The offset setter reads the axis direction. |
||||||
|
renderObject.offset = offset; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
class _RenderSingleChildViewport extends RenderBox |
||||||
|
with RenderObjectWithChildMixin<RenderBox> |
||||||
|
implements RenderAbstractViewport { |
||||||
|
_RenderSingleChildViewport({ |
||||||
|
required ViewportOffset offset, |
||||||
|
double cacheExtent = RenderAbstractViewport.defaultCacheExtent, |
||||||
|
RenderBox? child, |
||||||
|
}) : _offset = offset, |
||||||
|
_cacheExtent = cacheExtent { |
||||||
|
this.child = child; |
||||||
|
} |
||||||
|
|
||||||
|
ViewportOffset get offset => _offset; |
||||||
|
ViewportOffset _offset; |
||||||
|
|
||||||
|
set offset(ViewportOffset value) { |
||||||
|
if (value == _offset) return; |
||||||
|
if (attached) _offset.removeListener(_hasScrolled); |
||||||
|
_offset = value; |
||||||
|
if (attached) _offset.addListener(_hasScrolled); |
||||||
|
markNeedsLayout(); |
||||||
|
} |
||||||
|
|
||||||
|
/// {@macro flutter.rendering.RenderViewportBase.cacheExtent} |
||||||
|
double get cacheExtent => _cacheExtent; |
||||||
|
double _cacheExtent; |
||||||
|
|
||||||
|
set cacheExtent(double value) { |
||||||
|
if (value == _cacheExtent) return; |
||||||
|
_cacheExtent = value; |
||||||
|
markNeedsLayout(); |
||||||
|
} |
||||||
|
|
||||||
|
void _hasScrolled() { |
||||||
|
markNeedsPaint(); |
||||||
|
markNeedsSemanticsUpdate(); |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
void setupParentData(RenderObject child) { |
||||||
|
// We don't actually use the offset argument in BoxParentData, so let's |
||||||
|
// avoid allocating it at all. |
||||||
|
if (child.parentData is! ParentData) child.parentData = ParentData(); |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
bool get isRepaintBoundary => true; |
||||||
|
|
||||||
|
double get _viewportExtent { |
||||||
|
assert(hasSize); |
||||||
|
return size.height; |
||||||
|
} |
||||||
|
|
||||||
|
double get _minScrollExtent { |
||||||
|
assert(hasSize); |
||||||
|
return 0; |
||||||
|
} |
||||||
|
|
||||||
|
double get _maxScrollExtent { |
||||||
|
assert(hasSize); |
||||||
|
if (child == null) return 0; |
||||||
|
return math.max(0, child!.size.height - size.height); |
||||||
|
} |
||||||
|
|
||||||
|
BoxConstraints _getInnerConstraints(BoxConstraints constraints) { |
||||||
|
return constraints.widthConstraints(); |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
double computeMinIntrinsicWidth(double height) { |
||||||
|
if (child != null) return child!.getMinIntrinsicWidth(height); |
||||||
|
return 0; |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
double computeMaxIntrinsicWidth(double height) { |
||||||
|
if (child != null) return child!.getMaxIntrinsicWidth(height); |
||||||
|
return 0; |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
double computeMinIntrinsicHeight(double width) { |
||||||
|
if (child != null) return child!.getMinIntrinsicHeight(width); |
||||||
|
return 0; |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
double computeMaxIntrinsicHeight(double width) { |
||||||
|
if (child != null) return child!.getMaxIntrinsicHeight(width); |
||||||
|
return 0; |
||||||
|
} |
||||||
|
|
||||||
|
// We don't override computeDistanceToActualBaseline(), because we |
||||||
|
// want the default behavior (returning null). Otherwise, as you |
||||||
|
// scroll, it would shift in its parent if the parent was baseline-aligned, |
||||||
|
// which makes no sense. |
||||||
|
|
||||||
|
@override |
||||||
|
Size computeDryLayout(BoxConstraints constraints) { |
||||||
|
if (child == null) { |
||||||
|
return constraints.smallest; |
||||||
|
} |
||||||
|
final childSize = child!.getDryLayout(_getInnerConstraints(constraints)); |
||||||
|
return constraints.constrain(childSize); |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
void performLayout() { |
||||||
|
final constraints = this.constraints; |
||||||
|
if (child == null) { |
||||||
|
size = constraints.smallest; |
||||||
|
} else { |
||||||
|
child!.layout(_getInnerConstraints(constraints), parentUsesSize: true); |
||||||
|
size = constraints.constrain(child!.size); |
||||||
|
} |
||||||
|
|
||||||
|
offset |
||||||
|
..applyViewportDimension(_viewportExtent) |
||||||
|
..applyContentDimensions(_minScrollExtent, _maxScrollExtent); |
||||||
|
} |
||||||
|
|
||||||
|
Offset get _paintOffset => _paintOffsetForPosition(offset.pixels); |
||||||
|
|
||||||
|
Offset _paintOffsetForPosition(double position) { |
||||||
|
return Offset(0, -position); |
||||||
|
} |
||||||
|
|
||||||
|
bool _shouldClipAtPaintOffset(Offset paintOffset) { |
||||||
|
assert(child != null); |
||||||
|
return paintOffset.dx < 0 || |
||||||
|
paintOffset.dy < 0 || |
||||||
|
paintOffset.dx + child!.size.width > size.width || |
||||||
|
paintOffset.dy + child!.size.height > size.height; |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
void paint(PaintingContext context, Offset offset) { |
||||||
|
if (child != null) { |
||||||
|
final paintOffset = _paintOffset; |
||||||
|
|
||||||
|
void paintContents(PaintingContext context, Offset offset) { |
||||||
|
context.paintChild(child!, offset + paintOffset); |
||||||
|
} |
||||||
|
|
||||||
|
if (_shouldClipAtPaintOffset(paintOffset)) { |
||||||
|
_clipRectLayer.layer = context.pushClipRect( |
||||||
|
needsCompositing, |
||||||
|
offset, |
||||||
|
Offset.zero & size, |
||||||
|
paintContents, |
||||||
|
oldLayer: _clipRectLayer.layer, |
||||||
|
); |
||||||
|
} else { |
||||||
|
_clipRectLayer.layer = null; |
||||||
|
paintContents(context, offset); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
final _clipRectLayer = LayerHandle<ClipRectLayer>(); |
||||||
|
|
||||||
|
@override |
||||||
|
void applyPaintTransform(RenderBox child, Matrix4 transform) { |
||||||
|
final paintOffset = _paintOffset; |
||||||
|
transform.translate(paintOffset.dx, paintOffset.dy); |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
Rect? describeApproximatePaintClip(RenderObject? child) { |
||||||
|
if (child != null && _shouldClipAtPaintOffset(_paintOffset)) { |
||||||
|
return Offset.zero & size; |
||||||
|
} |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { |
||||||
|
if (child != null) { |
||||||
|
return result.addWithPaintOffset( |
||||||
|
offset: _paintOffset, |
||||||
|
position: position, |
||||||
|
hitTest: (result, transformed) { |
||||||
|
assert(transformed == position + -_paintOffset); |
||||||
|
return child!.hitTest(result, position: transformed); |
||||||
|
}, |
||||||
|
); |
||||||
|
} |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
RevealedOffset getOffsetToReveal(RenderObject target, double alignment, |
||||||
|
{Rect? rect}) { |
||||||
|
rect ??= target.paintBounds; |
||||||
|
if (target is! RenderBox) { |
||||||
|
return RevealedOffset(offset: offset.pixels, rect: rect); |
||||||
|
} |
||||||
|
|
||||||
|
final targetBox = target; |
||||||
|
final transform = targetBox.getTransformTo(child); |
||||||
|
final bounds = MatrixUtils.transformRect(transform, rect); |
||||||
|
|
||||||
|
final double leadingScrollOffset; |
||||||
|
final double targetMainAxisExtent; |
||||||
|
final double mainAxisExtent; |
||||||
|
|
||||||
|
mainAxisExtent = size.height; |
||||||
|
leadingScrollOffset = bounds.top; |
||||||
|
targetMainAxisExtent = bounds.height; |
||||||
|
|
||||||
|
final targetOffset = leadingScrollOffset - |
||||||
|
(mainAxisExtent - targetMainAxisExtent) * alignment; |
||||||
|
final targetRect = bounds.shift(_paintOffsetForPosition(targetOffset)); |
||||||
|
return RevealedOffset(offset: targetOffset, rect: targetRect); |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
void showOnScreen({ |
||||||
|
RenderObject? descendant, |
||||||
|
Rect? rect, |
||||||
|
Duration duration = Duration.zero, |
||||||
|
Curve curve = Curves.ease, |
||||||
|
}) { |
||||||
|
if (!offset.allowImplicitScrolling) { |
||||||
|
return super.showOnScreen( |
||||||
|
descendant: descendant, |
||||||
|
rect: rect, |
||||||
|
duration: duration, |
||||||
|
curve: curve, |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
final newRect = RenderViewportBase.showInViewport( |
||||||
|
descendant: descendant, |
||||||
|
viewport: this, |
||||||
|
offset: offset, |
||||||
|
rect: rect, |
||||||
|
duration: duration, |
||||||
|
curve: curve, |
||||||
|
); |
||||||
|
super.showOnScreen( |
||||||
|
rect: newRect, |
||||||
|
duration: duration, |
||||||
|
curve: curve, |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
Rect describeSemanticsClip(RenderObject child) { |
||||||
|
return Rect.fromLTRB( |
||||||
|
semanticBounds.left, |
||||||
|
semanticBounds.top - cacheExtent, |
||||||
|
semanticBounds.right, |
||||||
|
semanticBounds.bottom + cacheExtent, |
||||||
|
); |
||||||
|
} |
||||||
|
} |
@ -1,355 +0,0 @@ |
|||||||
import 'dart:ui'; |
|
||||||
|
|
||||||
import 'package:characters/characters.dart'; |
|
||||||
import 'package:flutter/services.dart'; |
|
||||||
|
|
||||||
import '../../models/documents/document.dart'; |
|
||||||
import '../../utils/diff_delta.dart'; |
|
||||||
import '../editor.dart'; |
|
||||||
import '../keyboard_listener.dart'; |
|
||||||
|
|
||||||
mixin RawEditorStateKeyboardMixin on EditorState { |
|
||||||
// Holds the last cursor location the user selected in the case the user tries |
|
||||||
// to select vertically past the end or beginning of the field. If they do, |
|
||||||
// then we need to keep the old cursor location so that we can go back to it |
|
||||||
// if they change their minds. Only used for moving selection up and down in a |
|
||||||
// multiline text field when selecting using the keyboard. |
|
||||||
int _cursorResetLocation = -1; |
|
||||||
|
|
||||||
// Whether we should reset the location of the cursor in the case the user |
|
||||||
// tries to select vertically past the end or beginning of the field. If they |
|
||||||
// do, then we need to keep the old cursor location so that we can go back to |
|
||||||
// it if they change their minds. Only used for resetting selection up and |
|
||||||
// down in a multiline text field when selecting using the keyboard. |
|
||||||
bool _wasSelectingVerticallyWithKeyboard = false; |
|
||||||
|
|
||||||
void handleCursorMovement( |
|
||||||
LogicalKeyboardKey key, |
|
||||||
bool wordModifier, |
|
||||||
bool lineModifier, |
|
||||||
bool shift, |
|
||||||
) { |
|
||||||
if (wordModifier && lineModifier) { |
|
||||||
// If both modifiers are down, nothing happens on any of the platforms. |
|
||||||
return; |
|
||||||
} |
|
||||||
final selection = widget.controller.selection; |
|
||||||
|
|
||||||
var newSelection = widget.controller.selection; |
|
||||||
|
|
||||||
final plainText = getTextEditingValue().text; |
|
||||||
|
|
||||||
final rightKey = key == LogicalKeyboardKey.arrowRight, |
|
||||||
leftKey = key == LogicalKeyboardKey.arrowLeft, |
|
||||||
upKey = key == LogicalKeyboardKey.arrowUp, |
|
||||||
downKey = key == LogicalKeyboardKey.arrowDown; |
|
||||||
|
|
||||||
if ((rightKey || leftKey) && !(rightKey && leftKey)) { |
|
||||||
newSelection = _jumpToBeginOrEndOfWord(newSelection, wordModifier, |
|
||||||
leftKey, rightKey, plainText, lineModifier, shift); |
|
||||||
} |
|
||||||
|
|
||||||
if (downKey || upKey) { |
|
||||||
newSelection = _handleMovingCursorVertically( |
|
||||||
upKey, downKey, shift, selection, newSelection, plainText); |
|
||||||
} |
|
||||||
|
|
||||||
if (!shift) { |
|
||||||
newSelection = |
|
||||||
_placeCollapsedSelection(selection, newSelection, leftKey, rightKey); |
|
||||||
} |
|
||||||
|
|
||||||
widget.controller.updateSelection(newSelection, ChangeSource.LOCAL); |
|
||||||
} |
|
||||||
|
|
||||||
// Handles shortcut functionality including cut, copy, paste and select all |
|
||||||
// using control/command + (X, C, V, A). |
|
||||||
// TODO: Add support for formatting shortcuts: Cmd+B (bold), Cmd+I (italic) |
|
||||||
// set editing value from clipboard for web |
|
||||||
Future<void> handleShortcut(InputShortcut? shortcut) async { |
|
||||||
final selection = widget.controller.selection; |
|
||||||
final plainText = getTextEditingValue().text; |
|
||||||
if (shortcut == InputShortcut.COPY) { |
|
||||||
if (!selection.isCollapsed) { |
|
||||||
await Clipboard.setData( |
|
||||||
ClipboardData(text: selection.textInside(plainText))); |
|
||||||
} |
|
||||||
return; |
|
||||||
} |
|
||||||
if (shortcut == InputShortcut.CUT && !widget.readOnly) { |
|
||||||
if (!selection.isCollapsed) { |
|
||||||
final data = selection.textInside(plainText); |
|
||||||
await Clipboard.setData(ClipboardData(text: data)); |
|
||||||
|
|
||||||
widget.controller.replaceText( |
|
||||||
selection.start, |
|
||||||
data.length, |
|
||||||
'', |
|
||||||
TextSelection.collapsed(offset: selection.start), |
|
||||||
); |
|
||||||
|
|
||||||
setTextEditingValue(TextEditingValue( |
|
||||||
text: |
|
||||||
selection.textBefore(plainText) + selection.textAfter(plainText), |
|
||||||
selection: TextSelection.collapsed(offset: selection.start), |
|
||||||
)); |
|
||||||
} |
|
||||||
return; |
|
||||||
} |
|
||||||
if (shortcut == InputShortcut.PASTE && !widget.readOnly) { |
|
||||||
final data = await Clipboard.getData(Clipboard.kTextPlain); |
|
||||||
if (data != null) { |
|
||||||
widget.controller.replaceText( |
|
||||||
selection.start, |
|
||||||
selection.end - selection.start, |
|
||||||
data.text, |
|
||||||
TextSelection.collapsed(offset: selection.start + data.text!.length), |
|
||||||
); |
|
||||||
} |
|
||||||
return; |
|
||||||
} |
|
||||||
if (shortcut == InputShortcut.SELECT_ALL && |
|
||||||
widget.enableInteractiveSelection) { |
|
||||||
widget.controller.updateSelection( |
|
||||||
selection.copyWith( |
|
||||||
baseOffset: 0, |
|
||||||
extentOffset: getTextEditingValue().text.length, |
|
||||||
), |
|
||||||
ChangeSource.REMOTE); |
|
||||||
return; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
void handleDelete(bool forward) { |
|
||||||
final selection = widget.controller.selection; |
|
||||||
final plainText = getTextEditingValue().text; |
|
||||||
var cursorPosition = selection.start; |
|
||||||
var textBefore = selection.textBefore(plainText); |
|
||||||
var textAfter = selection.textAfter(plainText); |
|
||||||
if (selection.isCollapsed) { |
|
||||||
if (!forward && textBefore.isNotEmpty) { |
|
||||||
final characterBoundary = |
|
||||||
_previousCharacter(textBefore.length, textBefore, true); |
|
||||||
textBefore = textBefore.substring(0, characterBoundary); |
|
||||||
cursorPosition = characterBoundary; |
|
||||||
} |
|
||||||
if (forward && textAfter.isNotEmpty && textAfter != '\n') { |
|
||||||
final deleteCount = _nextCharacter(0, textAfter, true); |
|
||||||
textAfter = textAfter.substring(deleteCount); |
|
||||||
} |
|
||||||
} |
|
||||||
final newSelection = TextSelection.collapsed(offset: cursorPosition); |
|
||||||
final newText = textBefore + textAfter; |
|
||||||
final size = plainText.length - newText.length; |
|
||||||
widget.controller.replaceText( |
|
||||||
cursorPosition, |
|
||||||
size, |
|
||||||
'', |
|
||||||
newSelection, |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
TextSelection _jumpToBeginOrEndOfWord( |
|
||||||
TextSelection newSelection, |
|
||||||
bool wordModifier, |
|
||||||
bool leftKey, |
|
||||||
bool rightKey, |
|
||||||
String plainText, |
|
||||||
bool lineModifier, |
|
||||||
bool shift) { |
|
||||||
if (wordModifier) { |
|
||||||
if (leftKey) { |
|
||||||
final textSelection = getRenderEditor()!.selectWordAtPosition( |
|
||||||
TextPosition( |
|
||||||
offset: _previousCharacter( |
|
||||||
newSelection.extentOffset, plainText, false))); |
|
||||||
return newSelection.copyWith(extentOffset: textSelection.baseOffset); |
|
||||||
} |
|
||||||
final textSelection = getRenderEditor()!.selectWordAtPosition( |
|
||||||
TextPosition( |
|
||||||
offset: |
|
||||||
_nextCharacter(newSelection.extentOffset, plainText, false))); |
|
||||||
return newSelection.copyWith(extentOffset: textSelection.extentOffset); |
|
||||||
} else if (lineModifier) { |
|
||||||
if (leftKey) { |
|
||||||
final textSelection = getRenderEditor()!.selectLineAtPosition( |
|
||||||
TextPosition( |
|
||||||
offset: _previousCharacter( |
|
||||||
newSelection.extentOffset, plainText, false))); |
|
||||||
return newSelection.copyWith(extentOffset: textSelection.baseOffset); |
|
||||||
} |
|
||||||
final startPoint = newSelection.extentOffset; |
|
||||||
if (startPoint < plainText.length) { |
|
||||||
final textSelection = getRenderEditor()! |
|
||||||
.selectLineAtPosition(TextPosition(offset: startPoint)); |
|
||||||
return newSelection.copyWith(extentOffset: textSelection.extentOffset); |
|
||||||
} |
|
||||||
return newSelection; |
|
||||||
} |
|
||||||
|
|
||||||
if (rightKey && newSelection.extentOffset < plainText.length) { |
|
||||||
final nextExtent = |
|
||||||
_nextCharacter(newSelection.extentOffset, plainText, true); |
|
||||||
final distance = nextExtent - newSelection.extentOffset; |
|
||||||
newSelection = newSelection.copyWith(extentOffset: nextExtent); |
|
||||||
if (shift) { |
|
||||||
_cursorResetLocation += distance; |
|
||||||
} |
|
||||||
return newSelection; |
|
||||||
} |
|
||||||
|
|
||||||
if (leftKey && newSelection.extentOffset > 0) { |
|
||||||
final previousExtent = |
|
||||||
_previousCharacter(newSelection.extentOffset, plainText, true); |
|
||||||
final distance = newSelection.extentOffset - previousExtent; |
|
||||||
newSelection = newSelection.copyWith(extentOffset: previousExtent); |
|
||||||
if (shift) { |
|
||||||
_cursorResetLocation -= distance; |
|
||||||
} |
|
||||||
return newSelection; |
|
||||||
} |
|
||||||
return newSelection; |
|
||||||
} |
|
||||||
|
|
||||||
/// Returns the index into the string of the next character boundary after the |
|
||||||
/// given index. |
|
||||||
/// |
|
||||||
/// The character boundary is determined by the characters package, so |
|
||||||
/// surrogate pairs and extended grapheme clusters are considered. |
|
||||||
/// |
|
||||||
/// The index must be between 0 and string.length, inclusive. If given |
|
||||||
/// string.length, string.length is returned. |
|
||||||
/// |
|
||||||
/// Setting includeWhitespace to false will only return the index of non-space |
|
||||||
/// characters. |
|
||||||
int _nextCharacter(int index, String string, bool includeWhitespace) { |
|
||||||
assert(index >= 0 && index <= string.length); |
|
||||||
if (index == string.length) { |
|
||||||
return string.length; |
|
||||||
} |
|
||||||
|
|
||||||
var count = 0; |
|
||||||
final remain = string.characters.skipWhile((currentString) { |
|
||||||
if (count <= index) { |
|
||||||
count += currentString.length; |
|
||||||
return true; |
|
||||||
} |
|
||||||
if (includeWhitespace) { |
|
||||||
return false; |
|
||||||
} |
|
||||||
return WHITE_SPACE.contains(currentString.codeUnitAt(0)); |
|
||||||
}); |
|
||||||
return string.length - remain.toString().length; |
|
||||||
} |
|
||||||
|
|
||||||
/// Returns the index into the string of the previous character boundary |
|
||||||
/// before the given index. |
|
||||||
/// |
|
||||||
/// The character boundary is determined by the characters package, so |
|
||||||
/// surrogate pairs and extended grapheme clusters are considered. |
|
||||||
/// |
|
||||||
/// The index must be between 0 and string.length, inclusive. If index is 0, |
|
||||||
/// 0 will be returned. |
|
||||||
/// |
|
||||||
/// Setting includeWhitespace to false will only return the index of non-space |
|
||||||
/// characters. |
|
||||||
int _previousCharacter(int index, String string, includeWhitespace) { |
|
||||||
assert(index >= 0 && index <= string.length); |
|
||||||
if (index == 0) { |
|
||||||
return 0; |
|
||||||
} |
|
||||||
|
|
||||||
var count = 0; |
|
||||||
int? lastNonWhitespace; |
|
||||||
for (final currentString in string.characters) { |
|
||||||
if (!includeWhitespace && |
|
||||||
!WHITE_SPACE.contains( |
|
||||||
currentString.characters.first.toString().codeUnitAt(0))) { |
|
||||||
lastNonWhitespace = count; |
|
||||||
} |
|
||||||
if (count + currentString.length >= index) { |
|
||||||
return includeWhitespace ? count : lastNonWhitespace ?? 0; |
|
||||||
} |
|
||||||
count += currentString.length; |
|
||||||
} |
|
||||||
return 0; |
|
||||||
} |
|
||||||
|
|
||||||
TextSelection _handleMovingCursorVertically( |
|
||||||
bool upKey, |
|
||||||
bool downKey, |
|
||||||
bool shift, |
|
||||||
TextSelection selection, |
|
||||||
TextSelection newSelection, |
|
||||||
String plainText) { |
|
||||||
final originPosition = TextPosition( |
|
||||||
offset: upKey ? selection.baseOffset : selection.extentOffset); |
|
||||||
|
|
||||||
final child = getRenderEditor()!.childAtPosition(originPosition); |
|
||||||
final localPosition = TextPosition( |
|
||||||
offset: originPosition.offset - child.getContainer().documentOffset); |
|
||||||
|
|
||||||
var position = upKey |
|
||||||
? child.getPositionAbove(localPosition) |
|
||||||
: child.getPositionBelow(localPosition); |
|
||||||
|
|
||||||
if (position == null) { |
|
||||||
final sibling = upKey |
|
||||||
? getRenderEditor()!.childBefore(child) |
|
||||||
: getRenderEditor()!.childAfter(child); |
|
||||||
if (sibling == null) { |
|
||||||
position = TextPosition(offset: upKey ? 0 : plainText.length - 1); |
|
||||||
} else { |
|
||||||
final finalOffset = Offset( |
|
||||||
child.getOffsetForCaret(localPosition).dx, |
|
||||||
sibling |
|
||||||
.getOffsetForCaret(TextPosition( |
|
||||||
offset: upKey ? sibling.getContainer().length - 1 : 0)) |
|
||||||
.dy); |
|
||||||
final siblingPosition = sibling.getPositionForOffset(finalOffset); |
|
||||||
position = TextPosition( |
|
||||||
offset: |
|
||||||
sibling.getContainer().documentOffset + siblingPosition.offset); |
|
||||||
} |
|
||||||
} else { |
|
||||||
position = TextPosition( |
|
||||||
offset: child.getContainer().documentOffset + position.offset); |
|
||||||
} |
|
||||||
|
|
||||||
if (position.offset == newSelection.extentOffset) { |
|
||||||
if (downKey) { |
|
||||||
newSelection = newSelection.copyWith(extentOffset: plainText.length); |
|
||||||
} else if (upKey) { |
|
||||||
newSelection = newSelection.copyWith(extentOffset: 0); |
|
||||||
} |
|
||||||
_wasSelectingVerticallyWithKeyboard = shift; |
|
||||||
return newSelection; |
|
||||||
} |
|
||||||
|
|
||||||
if (_wasSelectingVerticallyWithKeyboard && shift) { |
|
||||||
newSelection = newSelection.copyWith(extentOffset: _cursorResetLocation); |
|
||||||
_wasSelectingVerticallyWithKeyboard = false; |
|
||||||
return newSelection; |
|
||||||
} |
|
||||||
newSelection = newSelection.copyWith(extentOffset: position.offset); |
|
||||||
_cursorResetLocation = newSelection.extentOffset; |
|
||||||
return newSelection; |
|
||||||
} |
|
||||||
|
|
||||||
TextSelection _placeCollapsedSelection(TextSelection selection, |
|
||||||
TextSelection newSelection, bool leftKey, bool rightKey) { |
|
||||||
var newOffset = newSelection.extentOffset; |
|
||||||
if (!selection.isCollapsed) { |
|
||||||
if (leftKey) { |
|
||||||
newOffset = newSelection.baseOffset < newSelection.extentOffset |
|
||||||
? newSelection.baseOffset |
|
||||||
: newSelection.extentOffset; |
|
||||||
} else if (rightKey) { |
|
||||||
newOffset = newSelection.baseOffset > newSelection.extentOffset |
|
||||||
? newSelection.baseOffset |
|
||||||
: newSelection.extentOffset; |
|
||||||
} |
|
||||||
} |
|
||||||
return TextSelection.fromPosition(TextPosition(offset: newOffset)); |
|
||||||
} |
|
||||||
} |
|
@ -1,358 +0,0 @@ |
|||||||
import 'dart:convert'; |
|
||||||
import 'dart:io' as io; |
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart'; |
|
||||||
import 'package:flutter/material.dart'; |
|
||||||
import 'package:flutter/rendering.dart'; |
|
||||||
import 'package:flutter/services.dart'; |
|
||||||
import 'package:string_validator/string_validator.dart'; |
|
||||||
import 'package:tuple/tuple.dart'; |
|
||||||
|
|
||||||
import '../models/documents/attribute.dart'; |
|
||||||
import '../models/documents/document.dart'; |
|
||||||
import '../models/documents/nodes/block.dart'; |
|
||||||
import '../models/documents/nodes/leaf.dart' as leaf; |
|
||||||
import '../models/documents/nodes/line.dart'; |
|
||||||
import 'controller.dart'; |
|
||||||
import 'cursor.dart'; |
|
||||||
import 'default_styles.dart'; |
|
||||||
import 'delegate.dart'; |
|
||||||
import 'editor.dart'; |
|
||||||
import 'text_block.dart'; |
|
||||||
import 'text_line.dart'; |
|
||||||
import 'video_app.dart'; |
|
||||||
import 'youtube_video_app.dart'; |
|
||||||
|
|
||||||
class QuillSimpleViewer extends StatefulWidget { |
|
||||||
const QuillSimpleViewer({ |
|
||||||
required this.controller, |
|
||||||
required this.readOnly, |
|
||||||
this.customStyles, |
|
||||||
this.truncate = false, |
|
||||||
this.truncateScale, |
|
||||||
this.truncateAlignment, |
|
||||||
this.truncateHeight, |
|
||||||
this.truncateWidth, |
|
||||||
this.scrollBottomInset = 0, |
|
||||||
this.padding = EdgeInsets.zero, |
|
||||||
this.embedBuilder, |
|
||||||
Key? key, |
|
||||||
}) : assert(truncate || |
|
||||||
((truncateScale == null) && |
|
||||||
(truncateAlignment == null) && |
|
||||||
(truncateHeight == null) && |
|
||||||
(truncateWidth == null))), |
|
||||||
super(key: key); |
|
||||||
|
|
||||||
final QuillController controller; |
|
||||||
final DefaultStyles? customStyles; |
|
||||||
final bool truncate; |
|
||||||
final double? truncateScale; |
|
||||||
final Alignment? truncateAlignment; |
|
||||||
final double? truncateHeight; |
|
||||||
final double? truncateWidth; |
|
||||||
final double scrollBottomInset; |
|
||||||
final EdgeInsetsGeometry padding; |
|
||||||
final EmbedBuilder? embedBuilder; |
|
||||||
final bool readOnly; |
|
||||||
|
|
||||||
@override |
|
||||||
_QuillSimpleViewerState createState() => _QuillSimpleViewerState(); |
|
||||||
} |
|
||||||
|
|
||||||
class _QuillSimpleViewerState extends State<QuillSimpleViewer> |
|
||||||
with SingleTickerProviderStateMixin { |
|
||||||
late DefaultStyles _styles; |
|
||||||
final LayerLink _toolbarLayerLink = LayerLink(); |
|
||||||
final LayerLink _startHandleLayerLink = LayerLink(); |
|
||||||
final LayerLink _endHandleLayerLink = LayerLink(); |
|
||||||
late CursorCont _cursorCont; |
|
||||||
|
|
||||||
@override |
|
||||||
void initState() { |
|
||||||
super.initState(); |
|
||||||
|
|
||||||
_cursorCont = CursorCont( |
|
||||||
show: ValueNotifier<bool>(false), |
|
||||||
style: const CursorStyle( |
|
||||||
color: Colors.black, |
|
||||||
backgroundColor: Colors.grey, |
|
||||||
width: 2, |
|
||||||
radius: Radius.zero, |
|
||||||
offset: Offset.zero, |
|
||||||
), |
|
||||||
tickerProvider: this, |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
@override |
|
||||||
void didChangeDependencies() { |
|
||||||
super.didChangeDependencies(); |
|
||||||
final parentStyles = QuillStyles.getStyles(context, true); |
|
||||||
final defaultStyles = DefaultStyles.getInstance(context); |
|
||||||
_styles = (parentStyles != null) |
|
||||||
? defaultStyles.merge(parentStyles) |
|
||||||
: defaultStyles; |
|
||||||
|
|
||||||
if (widget.customStyles != null) { |
|
||||||
_styles = _styles.merge(widget.customStyles!); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
EmbedBuilder get embedBuilder => widget.embedBuilder ?? _defaultEmbedBuilder; |
|
||||||
|
|
||||||
Widget _defaultEmbedBuilder( |
|
||||||
BuildContext context, leaf.Embed node, bool readOnly) { |
|
||||||
assert(!kIsWeb, 'Please provide EmbedBuilder for Web'); |
|
||||||
switch (node.value.type) { |
|
||||||
case 'image': |
|
||||||
final imageUrl = _standardizeImageUrl(node.value.data); |
|
||||||
return imageUrl.startsWith('http') |
|
||||||
? Image.network(imageUrl) |
|
||||||
: isBase64(imageUrl) |
|
||||||
? Image.memory(base64.decode(imageUrl)) |
|
||||||
: Image.file(io.File(imageUrl)); |
|
||||||
case 'video': |
|
||||||
final videoUrl = node.value.data; |
|
||||||
if (videoUrl.contains('youtube.com') || videoUrl.contains('youtu.be')) { |
|
||||||
return YoutubeVideoApp( |
|
||||||
videoUrl: videoUrl, context: context, readOnly: readOnly); |
|
||||||
} |
|
||||||
return VideoApp( |
|
||||||
videoUrl: videoUrl, context: context, readOnly: readOnly); |
|
||||||
default: |
|
||||||
throw UnimplementedError( |
|
||||||
'Embeddable type "${node.value.type}" is not supported by default ' |
|
||||||
'embed builder of QuillEditor. You must pass your own builder ' |
|
||||||
'function to embedBuilder property of QuillEditor or QuillField ' |
|
||||||
'widgets.', |
|
||||||
); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
String _standardizeImageUrl(String url) { |
|
||||||
if (url.contains('base64')) { |
|
||||||
return url.split(',')[1]; |
|
||||||
} |
|
||||||
return url; |
|
||||||
} |
|
||||||
|
|
||||||
@override |
|
||||||
Widget build(BuildContext context) { |
|
||||||
final _doc = widget.controller.document; |
|
||||||
// if (_doc.isEmpty() && |
|
||||||
// !widget.focusNode.hasFocus && |
|
||||||
// widget.placeholder != null) { |
|
||||||
// _doc = Document.fromJson(jsonDecode( |
|
||||||
// '[{"attributes":{"placeholder":true},"insert":"${widget.placeholder}\\n"}]')); |
|
||||||
// } |
|
||||||
|
|
||||||
Widget child = CompositedTransformTarget( |
|
||||||
link: _toolbarLayerLink, |
|
||||||
child: Semantics( |
|
||||||
child: _SimpleViewer( |
|
||||||
document: _doc, |
|
||||||
textDirection: _textDirection, |
|
||||||
startHandleLayerLink: _startHandleLayerLink, |
|
||||||
endHandleLayerLink: _endHandleLayerLink, |
|
||||||
onSelectionChanged: _nullSelectionChanged, |
|
||||||
scrollBottomInset: widget.scrollBottomInset, |
|
||||||
padding: widget.padding, |
|
||||||
children: _buildChildren(_doc, context), |
|
||||||
), |
|
||||||
), |
|
||||||
); |
|
||||||
|
|
||||||
if (widget.truncate) { |
|
||||||
if (widget.truncateScale != null) { |
|
||||||
child = Container( |
|
||||||
height: widget.truncateHeight, |
|
||||||
child: Align( |
|
||||||
heightFactor: widget.truncateScale, |
|
||||||
widthFactor: widget.truncateScale, |
|
||||||
alignment: widget.truncateAlignment ?? Alignment.topLeft, |
|
||||||
child: Container( |
|
||||||
width: widget.truncateWidth! / widget.truncateScale!, |
|
||||||
child: SingleChildScrollView( |
|
||||||
physics: const NeverScrollableScrollPhysics(), |
|
||||||
child: Transform.scale( |
|
||||||
scale: widget.truncateScale!, |
|
||||||
alignment: |
|
||||||
widget.truncateAlignment ?? Alignment.topLeft, |
|
||||||
child: child))))); |
|
||||||
} else { |
|
||||||
child = Container( |
|
||||||
height: widget.truncateHeight, |
|
||||||
width: widget.truncateWidth, |
|
||||||
child: SingleChildScrollView( |
|
||||||
physics: const NeverScrollableScrollPhysics(), child: child)); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
return QuillStyles(data: _styles, child: child); |
|
||||||
} |
|
||||||
|
|
||||||
List<Widget> _buildChildren(Document doc, BuildContext context) { |
|
||||||
final result = <Widget>[]; |
|
||||||
final indentLevelCounts = <int, int>{}; |
|
||||||
for (final node in doc.root.children) { |
|
||||||
if (node is Line) { |
|
||||||
final editableTextLine = _getEditableTextLineFromNode(node, context); |
|
||||||
result.add(editableTextLine); |
|
||||||
} else if (node is Block) { |
|
||||||
final attrs = node.style.attributes; |
|
||||||
final editableTextBlock = EditableTextBlock( |
|
||||||
block: node, |
|
||||||
textDirection: _textDirection, |
|
||||||
scrollBottomInset: widget.scrollBottomInset, |
|
||||||
verticalSpacing: _getVerticalSpacingForBlock(node, _styles), |
|
||||||
textSelection: widget.controller.selection, |
|
||||||
color: Colors.black, |
|
||||||
styles: _styles, |
|
||||||
enableInteractiveSelection: false, |
|
||||||
hasFocus: false, |
|
||||||
contentPadding: attrs.containsKey(Attribute.codeBlock.key) |
|
||||||
? const EdgeInsets.all(16) |
|
||||||
: null, |
|
||||||
embedBuilder: embedBuilder, |
|
||||||
cursorCont: _cursorCont, |
|
||||||
indentLevelCounts: indentLevelCounts, |
|
||||||
onCheckboxTap: _handleCheckboxTap, |
|
||||||
readOnly: widget.readOnly); |
|
||||||
result.add(editableTextBlock); |
|
||||||
} else { |
|
||||||
throw StateError('Unreachable.'); |
|
||||||
} |
|
||||||
} |
|
||||||
return result; |
|
||||||
} |
|
||||||
|
|
||||||
/// Updates the checkbox positioned at [offset] in document |
|
||||||
/// by changing its attribute according to [value]. |
|
||||||
void _handleCheckboxTap(int offset, bool value) { |
|
||||||
// readonly - do nothing |
|
||||||
} |
|
||||||
|
|
||||||
TextDirection get _textDirection { |
|
||||||
final result = Directionality.of(context); |
|
||||||
return result; |
|
||||||
} |
|
||||||
|
|
||||||
EditableTextLine _getEditableTextLineFromNode( |
|
||||||
Line node, BuildContext context) { |
|
||||||
final textLine = TextLine( |
|
||||||
line: node, |
|
||||||
textDirection: _textDirection, |
|
||||||
embedBuilder: embedBuilder, |
|
||||||
styles: _styles, |
|
||||||
readOnly: widget.readOnly, |
|
||||||
); |
|
||||||
final editableTextLine = EditableTextLine( |
|
||||||
node, |
|
||||||
null, |
|
||||||
textLine, |
|
||||||
0, |
|
||||||
_getVerticalSpacingForLine(node, _styles), |
|
||||||
_textDirection, |
|
||||||
widget.controller.selection, |
|
||||||
Colors.black, |
|
||||||
//widget.selectionColor, |
|
||||||
false, |
|
||||||
//enableInteractiveSelection, |
|
||||||
false, |
|
||||||
//_hasFocus, |
|
||||||
MediaQuery.of(context).devicePixelRatio, |
|
||||||
_cursorCont); |
|
||||||
return editableTextLine; |
|
||||||
} |
|
||||||
|
|
||||||
Tuple2<double, double> _getVerticalSpacingForLine( |
|
||||||
Line line, DefaultStyles? defaultStyles) { |
|
||||||
final attrs = line.style.attributes; |
|
||||||
if (attrs.containsKey(Attribute.header.key)) { |
|
||||||
final int? level = attrs[Attribute.header.key]!.value; |
|
||||||
switch (level) { |
|
||||||
case 1: |
|
||||||
return defaultStyles!.h1!.verticalSpacing; |
|
||||||
case 2: |
|
||||||
return defaultStyles!.h2!.verticalSpacing; |
|
||||||
case 3: |
|
||||||
return defaultStyles!.h3!.verticalSpacing; |
|
||||||
default: |
|
||||||
throw 'Invalid level $level'; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
return defaultStyles!.paragraph!.verticalSpacing; |
|
||||||
} |
|
||||||
|
|
||||||
Tuple2<double, double> _getVerticalSpacingForBlock( |
|
||||||
Block node, DefaultStyles? defaultStyles) { |
|
||||||
final attrs = node.style.attributes; |
|
||||||
if (attrs.containsKey(Attribute.blockQuote.key)) { |
|
||||||
return defaultStyles!.quote!.verticalSpacing; |
|
||||||
} else if (attrs.containsKey(Attribute.codeBlock.key)) { |
|
||||||
return defaultStyles!.code!.verticalSpacing; |
|
||||||
} else if (attrs.containsKey(Attribute.indent.key)) { |
|
||||||
return defaultStyles!.indent!.verticalSpacing; |
|
||||||
} |
|
||||||
return defaultStyles!.lists!.verticalSpacing; |
|
||||||
} |
|
||||||
|
|
||||||
void _nullSelectionChanged( |
|
||||||
TextSelection selection, SelectionChangedCause cause) {} |
|
||||||
} |
|
||||||
|
|
||||||
class _SimpleViewer extends MultiChildRenderObjectWidget { |
|
||||||
_SimpleViewer({ |
|
||||||
required List<Widget> children, |
|
||||||
required this.document, |
|
||||||
required this.textDirection, |
|
||||||
required this.startHandleLayerLink, |
|
||||||
required this.endHandleLayerLink, |
|
||||||
required this.onSelectionChanged, |
|
||||||
required this.scrollBottomInset, |
|
||||||
this.padding = EdgeInsets.zero, |
|
||||||
Key? key, |
|
||||||
}) : super(key: key, children: children); |
|
||||||
|
|
||||||
final Document document; |
|
||||||
final TextDirection textDirection; |
|
||||||
final LayerLink startHandleLayerLink; |
|
||||||
final LayerLink endHandleLayerLink; |
|
||||||
final TextSelectionChangedHandler onSelectionChanged; |
|
||||||
final double scrollBottomInset; |
|
||||||
final EdgeInsetsGeometry padding; |
|
||||||
|
|
||||||
@override |
|
||||||
RenderEditor createRenderObject(BuildContext context) { |
|
||||||
return RenderEditor( |
|
||||||
null, |
|
||||||
textDirection, |
|
||||||
scrollBottomInset, |
|
||||||
padding, |
|
||||||
document, |
|
||||||
const TextSelection(baseOffset: 0, extentOffset: 0), |
|
||||||
false, |
|
||||||
// hasFocus, |
|
||||||
onSelectionChanged, |
|
||||||
startHandleLayerLink, |
|
||||||
endHandleLayerLink, |
|
||||||
const EdgeInsets.fromLTRB(4, 4, 4, 5), |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
@override |
|
||||||
void updateRenderObject( |
|
||||||
BuildContext context, covariant RenderEditor renderObject) { |
|
||||||
renderObject |
|
||||||
..document = document |
|
||||||
..setContainer(document.root) |
|
||||||
..textDirection = textDirection |
|
||||||
..setStartHandleLayerLink(startHandleLayerLink) |
|
||||||
..setEndHandleLayerLink(endHandleLayerLink) |
|
||||||
..onSelectionChanged = onSelectionChanged |
|
||||||
..setScrollBottomInset(scrollBottomInset) |
|
||||||
..setPadding(padding); |
|
||||||
} |
|
||||||
} |
|
@ -0,0 +1,22 @@ |
|||||||
|
import 'package:flutter/material.dart'; |
||||||
|
|
||||||
|
class QuillBulletPoint extends StatelessWidget { |
||||||
|
const QuillBulletPoint({ |
||||||
|
required this.style, |
||||||
|
required this.width, |
||||||
|
Key? key, |
||||||
|
}) : super(key: key); |
||||||
|
|
||||||
|
final TextStyle style; |
||||||
|
final double width; |
||||||
|
|
||||||
|
@override |
||||||
|
Widget build(BuildContext context) { |
||||||
|
return Container( |
||||||
|
alignment: AlignmentDirectional.topEnd, |
||||||
|
width: width, |
||||||
|
padding: const EdgeInsetsDirectional.only(end: 13), |
||||||
|
child: Text('•', style: style), |
||||||
|
); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,78 @@ |
|||||||
|
import 'package:flutter/material.dart'; |
||||||
|
|
||||||
|
class CheckboxPoint extends StatefulWidget { |
||||||
|
const CheckboxPoint({ |
||||||
|
required this.size, |
||||||
|
required this.value, |
||||||
|
required this.enabled, |
||||||
|
required this.onChanged, |
||||||
|
this.uiBuilder, |
||||||
|
Key? key, |
||||||
|
}) : super(key: key); |
||||||
|
|
||||||
|
final double size; |
||||||
|
final bool value; |
||||||
|
final bool enabled; |
||||||
|
final ValueChanged<bool> onChanged; |
||||||
|
final QuillCheckboxBuilder? uiBuilder; |
||||||
|
|
||||||
|
@override |
||||||
|
_CheckboxPointState createState() => _CheckboxPointState(); |
||||||
|
} |
||||||
|
|
||||||
|
class _CheckboxPointState extends State<CheckboxPoint> { |
||||||
|
@override |
||||||
|
Widget build(BuildContext context) { |
||||||
|
if (widget.uiBuilder != null) { |
||||||
|
return widget.uiBuilder!.build( |
||||||
|
context: context, |
||||||
|
isChecked: widget.value, |
||||||
|
onChanged: widget.onChanged, |
||||||
|
); |
||||||
|
} |
||||||
|
final theme = Theme.of(context); |
||||||
|
final fillColor = widget.value |
||||||
|
? (widget.enabled |
||||||
|
? theme.colorScheme.primary |
||||||
|
: theme.colorScheme.onSurface.withOpacity(0.5)) |
||||||
|
: theme.colorScheme.surface; |
||||||
|
final borderColor = widget.value |
||||||
|
? (widget.enabled |
||||||
|
? theme.colorScheme.primary |
||||||
|
: theme.colorScheme.onSurface.withOpacity(0)) |
||||||
|
: (widget.enabled |
||||||
|
? theme.colorScheme.onSurface.withOpacity(0.5) |
||||||
|
: theme.colorScheme.onSurface.withOpacity(0.3)); |
||||||
|
return Center( |
||||||
|
child: SizedBox( |
||||||
|
width: widget.size, |
||||||
|
height: widget.size, |
||||||
|
child: Material( |
||||||
|
color: fillColor, |
||||||
|
shape: RoundedRectangleBorder( |
||||||
|
side: BorderSide( |
||||||
|
color: borderColor, |
||||||
|
), |
||||||
|
borderRadius: BorderRadius.circular(2), |
||||||
|
), |
||||||
|
child: InkWell( |
||||||
|
onTap: |
||||||
|
widget.enabled ? () => widget.onChanged(!widget.value) : null, |
||||||
|
child: widget.value |
||||||
|
? Icon(Icons.check, |
||||||
|
size: widget.size, color: theme.colorScheme.onPrimary) |
||||||
|
: null, |
||||||
|
), |
||||||
|
), |
||||||
|
), |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
abstract class QuillCheckboxBuilder { |
||||||
|
Widget build({ |
||||||
|
required BuildContext context, |
||||||
|
required bool isChecked, |
||||||
|
required ValueChanged<bool> onChanged, |
||||||
|
}); |
||||||
|
} |
@ -0,0 +1,108 @@ |
|||||||
|
import 'package:flutter/material.dart'; |
||||||
|
|
||||||
|
import '../../models/documents/attribute.dart'; |
||||||
|
import '../text_block.dart'; |
||||||
|
|
||||||
|
class QuillNumberPoint extends StatelessWidget { |
||||||
|
const QuillNumberPoint({ |
||||||
|
required this.index, |
||||||
|
required this.indentLevelCounts, |
||||||
|
required this.count, |
||||||
|
required this.style, |
||||||
|
required this.width, |
||||||
|
required this.attrs, |
||||||
|
this.withDot = true, |
||||||
|
this.padding = 0.0, |
||||||
|
Key? key, |
||||||
|
}) : super(key: key); |
||||||
|
|
||||||
|
final int index; |
||||||
|
final Map<int?, int> indentLevelCounts; |
||||||
|
final int count; |
||||||
|
final TextStyle style; |
||||||
|
final double width; |
||||||
|
final Map<String, Attribute> attrs; |
||||||
|
final bool withDot; |
||||||
|
final double padding; |
||||||
|
|
||||||
|
@override |
||||||
|
Widget build(BuildContext context) { |
||||||
|
var s = index.toString(); |
||||||
|
int? level = 0; |
||||||
|
if (!attrs.containsKey(Attribute.indent.key) && |
||||||
|
!indentLevelCounts.containsKey(1)) { |
||||||
|
indentLevelCounts.clear(); |
||||||
|
return Container( |
||||||
|
alignment: AlignmentDirectional.topEnd, |
||||||
|
width: width, |
||||||
|
padding: EdgeInsetsDirectional.only(end: padding), |
||||||
|
child: Text(withDot ? '$s.' : s, style: style), |
||||||
|
); |
||||||
|
} |
||||||
|
if (attrs.containsKey(Attribute.indent.key)) { |
||||||
|
level = attrs[Attribute.indent.key]!.value; |
||||||
|
} else { |
||||||
|
// first level but is back from previous indent level |
||||||
|
// supposed to be "2." |
||||||
|
indentLevelCounts[0] = 1; |
||||||
|
} |
||||||
|
if (indentLevelCounts.containsKey(level! + 1)) { |
||||||
|
// last visited level is done, going up |
||||||
|
indentLevelCounts.remove(level + 1); |
||||||
|
} |
||||||
|
final count = (indentLevelCounts[level] ?? 0) + 1; |
||||||
|
indentLevelCounts[level] = count; |
||||||
|
|
||||||
|
s = count.toString(); |
||||||
|
if (level % 3 == 1) { |
||||||
|
// a. b. c. d. e. ... |
||||||
|
s = _toExcelSheetColumnTitle(count); |
||||||
|
} else if (level % 3 == 2) { |
||||||
|
// i. ii. iii. ... |
||||||
|
s = _intToRoman(count); |
||||||
|
} |
||||||
|
// level % 3 == 0 goes back to 1. 2. 3. |
||||||
|
|
||||||
|
return Container( |
||||||
|
alignment: AlignmentDirectional.topEnd, |
||||||
|
width: width, |
||||||
|
padding: EdgeInsetsDirectional.only(end: padding), |
||||||
|
child: Text(withDot ? '$s.' : s, style: style), |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
String _toExcelSheetColumnTitle(int n) { |
||||||
|
final result = StringBuffer(); |
||||||
|
while (n > 0) { |
||||||
|
n--; |
||||||
|
result.write(String.fromCharCode((n % 26).floor() + 97)); |
||||||
|
n = (n / 26).floor(); |
||||||
|
} |
||||||
|
|
||||||
|
return result.toString().split('').reversed.join(); |
||||||
|
} |
||||||
|
|
||||||
|
String _intToRoman(int input) { |
||||||
|
var num = input; |
||||||
|
|
||||||
|
if (num < 0) { |
||||||
|
return ''; |
||||||
|
} else if (num == 0) { |
||||||
|
return 'nulla'; |
||||||
|
} |
||||||
|
|
||||||
|
final builder = StringBuffer(); |
||||||
|
for (var a = 0; a < arabianRomanNumbers.length; a++) { |
||||||
|
final times = (num / arabianRomanNumbers[a]) |
||||||
|
.truncate(); // equals 1 only when arabianRomanNumbers[a] = num |
||||||
|
// executes n times where n is the number of times you have to add |
||||||
|
// the current roman number value to reach current num. |
||||||
|
builder.write(romanNumbers[a] * times); |
||||||
|
num -= times * |
||||||
|
arabianRomanNumbers[ |
||||||
|
a]; // subtract previous roman number value from num |
||||||
|
} |
||||||
|
|
||||||
|
return builder.toString().toLowerCase(); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,3 @@ |
|||||||
|
export 'bullet_point.dart'; |
||||||
|
export 'checkbox_point.dart'; |
||||||
|
export 'number_point.dart'; |
@ -0,0 +1,154 @@ |
|||||||
|
import 'package:flutter/foundation.dart'; |
||||||
|
import 'package:flutter/material.dart'; |
||||||
|
|
||||||
|
import '../../models/documents/attribute.dart'; |
||||||
|
import '../../models/documents/style.dart'; |
||||||
|
import '../../models/themes/quill_icon_theme.dart'; |
||||||
|
import '../controller.dart'; |
||||||
|
import '../toolbar.dart'; |
||||||
|
|
||||||
|
class SelectAlignmentButton extends StatefulWidget { |
||||||
|
const SelectAlignmentButton({ |
||||||
|
required this.controller, |
||||||
|
this.iconSize = kDefaultIconSize, |
||||||
|
this.iconTheme, |
||||||
|
this.showLeftAlignment, |
||||||
|
this.showCenterAlignment, |
||||||
|
this.showRightAlignment, |
||||||
|
this.showJustifyAlignment, |
||||||
|
Key? key, |
||||||
|
}) : super(key: key); |
||||||
|
|
||||||
|
final QuillController controller; |
||||||
|
final double iconSize; |
||||||
|
|
||||||
|
final QuillIconTheme? iconTheme; |
||||||
|
final bool? showLeftAlignment; |
||||||
|
final bool? showCenterAlignment; |
||||||
|
final bool? showRightAlignment; |
||||||
|
final bool? showJustifyAlignment; |
||||||
|
|
||||||
|
@override |
||||||
|
_SelectAlignmentButtonState createState() => _SelectAlignmentButtonState(); |
||||||
|
} |
||||||
|
|
||||||
|
class _SelectAlignmentButtonState extends State<SelectAlignmentButton> { |
||||||
|
Attribute? _value; |
||||||
|
|
||||||
|
Style get _selectionStyle => widget.controller.getSelectionStyle(); |
||||||
|
|
||||||
|
@override |
||||||
|
void initState() { |
||||||
|
super.initState(); |
||||||
|
setState(() { |
||||||
|
_value = _selectionStyle.attributes[Attribute.align.key] ?? |
||||||
|
Attribute.leftAlignment; |
||||||
|
}); |
||||||
|
widget.controller.addListener(_didChangeEditingValue); |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
Widget build(BuildContext context) { |
||||||
|
final _valueToText = <Attribute, String>{ |
||||||
|
if (widget.showLeftAlignment!) |
||||||
|
Attribute.leftAlignment: Attribute.leftAlignment.value!, |
||||||
|
if (widget.showCenterAlignment!) |
||||||
|
Attribute.centerAlignment: Attribute.centerAlignment.value!, |
||||||
|
if (widget.showRightAlignment!) |
||||||
|
Attribute.rightAlignment: Attribute.rightAlignment.value!, |
||||||
|
if (widget.showJustifyAlignment!) |
||||||
|
Attribute.justifyAlignment: Attribute.justifyAlignment.value!, |
||||||
|
}; |
||||||
|
|
||||||
|
final _valueAttribute = <Attribute>[ |
||||||
|
if (widget.showLeftAlignment!) Attribute.leftAlignment, |
||||||
|
if (widget.showCenterAlignment!) Attribute.centerAlignment, |
||||||
|
if (widget.showRightAlignment!) Attribute.rightAlignment, |
||||||
|
if (widget.showJustifyAlignment!) Attribute.justifyAlignment |
||||||
|
]; |
||||||
|
final _valueString = <String>[ |
||||||
|
if (widget.showLeftAlignment!) Attribute.leftAlignment.value!, |
||||||
|
if (widget.showCenterAlignment!) Attribute.centerAlignment.value!, |
||||||
|
if (widget.showRightAlignment!) Attribute.rightAlignment.value!, |
||||||
|
if (widget.showJustifyAlignment!) Attribute.justifyAlignment.value!, |
||||||
|
]; |
||||||
|
|
||||||
|
final theme = Theme.of(context); |
||||||
|
|
||||||
|
final buttonCount = ((widget.showLeftAlignment!) ? 1 : 0) + |
||||||
|
((widget.showCenterAlignment!) ? 1 : 0) + |
||||||
|
((widget.showRightAlignment!) ? 1 : 0) + |
||||||
|
((widget.showJustifyAlignment!) ? 1 : 0); |
||||||
|
|
||||||
|
return Row( |
||||||
|
mainAxisSize: MainAxisSize.min, |
||||||
|
children: List.generate(buttonCount, (index) { |
||||||
|
return Padding( |
||||||
|
padding: EdgeInsets.symmetric(horizontal: !kIsWeb ? 1.0 : 5.0), |
||||||
|
child: ConstrainedBox( |
||||||
|
constraints: BoxConstraints.tightFor( |
||||||
|
width: widget.iconSize * kIconButtonFactor, |
||||||
|
height: widget.iconSize * kIconButtonFactor, |
||||||
|
), |
||||||
|
child: RawMaterialButton( |
||||||
|
hoverElevation: 0, |
||||||
|
highlightElevation: 0, |
||||||
|
elevation: 0, |
||||||
|
visualDensity: VisualDensity.compact, |
||||||
|
shape: RoundedRectangleBorder( |
||||||
|
borderRadius: BorderRadius.circular(2)), |
||||||
|
fillColor: _valueToText[_value] == _valueString[index] |
||||||
|
? (widget.iconTheme?.iconSelectedFillColor ?? |
||||||
|
theme.toggleableActiveColor) |
||||||
|
: (widget.iconTheme?.iconUnselectedFillColor ?? |
||||||
|
theme.canvasColor), |
||||||
|
onPressed: () => _valueAttribute[index] == Attribute.leftAlignment |
||||||
|
? widget.controller |
||||||
|
.formatSelection(Attribute.clone(Attribute.align, null)) |
||||||
|
: widget.controller.formatSelection(_valueAttribute[index]), |
||||||
|
child: Icon( |
||||||
|
_valueString[index] == Attribute.leftAlignment.value |
||||||
|
? Icons.format_align_left |
||||||
|
: _valueString[index] == Attribute.centerAlignment.value |
||||||
|
? Icons.format_align_center |
||||||
|
: _valueString[index] == Attribute.rightAlignment.value |
||||||
|
? Icons.format_align_right |
||||||
|
: Icons.format_align_justify, |
||||||
|
size: widget.iconSize, |
||||||
|
color: _valueToText[_value] == _valueString[index] |
||||||
|
? (widget.iconTheme?.iconSelectedColor ?? |
||||||
|
theme.primaryIconTheme.color) |
||||||
|
: (widget.iconTheme?.iconUnselectedColor ?? |
||||||
|
theme.iconTheme.color), |
||||||
|
), |
||||||
|
), |
||||||
|
), |
||||||
|
); |
||||||
|
}), |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
void _didChangeEditingValue() { |
||||||
|
setState(() { |
||||||
|
_value = _selectionStyle.attributes[Attribute.align.key] ?? |
||||||
|
Attribute.leftAlignment; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
void didUpdateWidget(covariant SelectAlignmentButton oldWidget) { |
||||||
|
super.didUpdateWidget(oldWidget); |
||||||
|
if (oldWidget.controller != widget.controller) { |
||||||
|
oldWidget.controller.removeListener(_didChangeEditingValue); |
||||||
|
widget.controller.addListener(_didChangeEditingValue); |
||||||
|
_value = _selectionStyle.attributes[Attribute.align.key] ?? |
||||||
|
Attribute.leftAlignment; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
void dispose() { |
||||||
|
widget.controller.removeListener(_didChangeEditingValue); |
||||||
|
super.dispose(); |
||||||
|
} |
||||||
|
} |
@ -1,3 +0,0 @@ |
|||||||
/// TODO: Remove this file in the next breaking release, because implementation |
|
||||||
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
|
||||||
export '../src/widgets/keyboard_listener.dart'; |
|
@ -1,3 +0,0 @@ |
|||||||
/// TODO: Remove this file in the next breaking release, because implementation |
|
||||||
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
|
||||||
export '../../src/widgets/raw_editor/raw_editor_state_keyboard_mixin.dart'; |
|
@ -1,3 +0,0 @@ |
|||||||
/// TODO: Remove this file in the next breaking release, because implementation |
|
||||||
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
|
||||||
export '../src/widgets/simple_viewer.dart'; |
|
Loading…
Reference in new issue