zefyr

buttons.dart 16KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586
  1. // Copyright (c) 2018, the Zefyr project authors. Please see the AUTHORS file
  2. // for details. All rights reserved. Use of this source code is governed by a
  3. // BSD-style license that can be found in the LICENSE file.
  4. import 'package:flutter/material.dart';
  5. import 'package:flutter/services.dart';
  6. import 'package:notus/notus.dart';
  7. import 'package:url_launcher/url_launcher.dart';
  8. import 'scope.dart';
  9. import 'theme.dart';
  10. import 'toolbar.dart';
  11. /// A button used in [ZefyrToolbar].
  12. ///
  13. /// Create an instance of this widget with [ZefyrButton.icon] or
  14. /// [ZefyrButton.text] constructors.
  15. ///
  16. /// Toolbar buttons are normally created by a [ZefyrToolbarDelegate].
  17. class ZefyrButton extends StatelessWidget {
  18. /// Creates a toolbar button with an icon.
  19. ZefyrButton.icon({
  20. @required this.action,
  21. @required IconData icon,
  22. double iconSize,
  23. this.onPressed,
  24. }) : assert(action != null),
  25. assert(icon != null),
  26. _icon = icon,
  27. _iconSize = iconSize,
  28. _text = null,
  29. _textStyle = null,
  30. super();
  31. /// Creates a toolbar button containing text.
  32. ///
  33. /// Note that [ZefyrButton] has fixed width and does not expand to accommodate
  34. /// long texts.
  35. ZefyrButton.text({
  36. @required this.action,
  37. @required String text,
  38. TextStyle style,
  39. this.onPressed,
  40. }) : assert(action != null),
  41. assert(text != null),
  42. _icon = null,
  43. _iconSize = null,
  44. _text = text,
  45. _textStyle = style,
  46. super();
  47. /// Toolbar action associated with this button.
  48. final ZefyrToolbarAction action;
  49. final IconData _icon;
  50. final double _iconSize;
  51. final String _text;
  52. final TextStyle _textStyle;
  53. /// Callback to trigger when this button is tapped.
  54. final VoidCallback onPressed;
  55. bool get isAttributeAction {
  56. return kZefyrToolbarAttributeActions.keys.contains(action);
  57. }
  58. @override
  59. Widget build(BuildContext context) {
  60. final toolbar = ZefyrToolbar.of(context);
  61. final editor = toolbar.editor;
  62. final toolbarTheme = ZefyrTheme.of(context).toolbarTheme;
  63. final pressedHandler = _getPressedHandler(editor, toolbar);
  64. final iconColor = (pressedHandler == null)
  65. ? toolbarTheme.disabledIconColor
  66. : toolbarTheme.iconColor;
  67. if (_icon != null) {
  68. return RawZefyrButton.icon(
  69. action: action,
  70. icon: _icon,
  71. size: _iconSize,
  72. iconColor: iconColor,
  73. color: _getColor(editor, toolbarTheme),
  74. onPressed: _getPressedHandler(editor, toolbar),
  75. );
  76. } else {
  77. assert(_text != null);
  78. var style = _textStyle ?? TextStyle();
  79. style = style.copyWith(color: iconColor);
  80. return RawZefyrButton(
  81. action: action,
  82. child: Text(_text, style: style),
  83. color: _getColor(editor, toolbarTheme),
  84. onPressed: _getPressedHandler(editor, toolbar),
  85. );
  86. }
  87. }
  88. Color _getColor(ZefyrScope editor, ZefyrToolbarTheme theme) {
  89. if (isAttributeAction) {
  90. final attribute = kZefyrToolbarAttributeActions[action];
  91. final isToggled = (attribute is NotusAttribute)
  92. ? editor.selectionStyle.containsSame(attribute)
  93. : editor.selectionStyle.contains(attribute);
  94. return isToggled ? theme.toggleColor : null;
  95. }
  96. return null;
  97. }
  98. VoidCallback _getPressedHandler(
  99. ZefyrScope editor, ZefyrToolbarState toolbar) {
  100. if (onPressed != null) {
  101. return onPressed;
  102. } else if (isAttributeAction) {
  103. final attribute = kZefyrToolbarAttributeActions[action];
  104. if (attribute is NotusAttribute) {
  105. return () => _toggleAttribute(attribute, editor);
  106. }
  107. } else if (action == ZefyrToolbarAction.close) {
  108. return () => toolbar.closeOverlay();
  109. } else if (action == ZefyrToolbarAction.hideKeyboard) {
  110. return () => editor.hideKeyboard();
  111. }
  112. return null;
  113. }
  114. void _toggleAttribute(NotusAttribute attribute, ZefyrScope editor) {
  115. final isToggled = editor.selectionStyle.containsSame(attribute);
  116. if (isToggled) {
  117. editor.formatSelection(attribute.unset);
  118. } else {
  119. editor.formatSelection(attribute);
  120. }
  121. }
  122. }
  123. /// Raw button widget used by [ZefyrToolbar].
  124. ///
  125. /// See also:
  126. ///
  127. /// * [ZefyrButton], which wraps this widget and implements most of the
  128. /// action-specific logic.
  129. class RawZefyrButton extends StatelessWidget {
  130. const RawZefyrButton({
  131. Key key,
  132. @required this.action,
  133. @required this.child,
  134. @required this.color,
  135. @required this.onPressed,
  136. }) : super(key: key);
  137. /// Creates a [RawZefyrButton] containing an icon.
  138. RawZefyrButton.icon({
  139. @required this.action,
  140. @required IconData icon,
  141. double size,
  142. Color iconColor,
  143. @required this.color,
  144. @required this.onPressed,
  145. }) : child = Icon(icon, size: size, color: iconColor),
  146. super();
  147. /// Toolbar action associated with this button.
  148. final ZefyrToolbarAction action;
  149. /// Child widget to show inside this button. Usually an icon.
  150. final Widget child;
  151. /// Background color of this button.
  152. final Color color;
  153. /// Callback to trigger when this button is pressed.
  154. final VoidCallback onPressed;
  155. /// Returns `true` if this button is currently toggled on.
  156. bool get isToggled => color != null;
  157. @override
  158. Widget build(BuildContext context) {
  159. final theme = Theme.of(context);
  160. final width = theme.buttonTheme.constraints.minHeight + 4.0;
  161. final constraints = theme.buttonTheme.constraints.copyWith(
  162. minWidth: width, maxHeight: theme.buttonTheme.constraints.minHeight);
  163. final radius = BorderRadius.all(Radius.circular(3.0));
  164. return Padding(
  165. padding: const EdgeInsets.symmetric(horizontal: 1.0, vertical: 6.0),
  166. child: RawMaterialButton(
  167. shape: RoundedRectangleBorder(borderRadius: radius),
  168. elevation: 0.0,
  169. fillColor: color,
  170. constraints: constraints,
  171. onPressed: onPressed,
  172. child: child,
  173. ),
  174. );
  175. }
  176. }
  177. /// Controls heading styles.
  178. ///
  179. /// When pressed, this button displays overlay toolbar with three
  180. /// buttons for each heading level.
  181. class HeadingButton extends StatefulWidget {
  182. const HeadingButton({Key key}) : super(key: key);
  183. @override
  184. _HeadingButtonState createState() => _HeadingButtonState();
  185. }
  186. class _HeadingButtonState extends State<HeadingButton> {
  187. @override
  188. Widget build(BuildContext context) {
  189. final toolbar = ZefyrToolbar.of(context);
  190. return toolbar.buildButton(
  191. context,
  192. ZefyrToolbarAction.heading,
  193. onPressed: showOverlay,
  194. );
  195. }
  196. void showOverlay() {
  197. final toolbar = ZefyrToolbar.of(context);
  198. toolbar.showOverlay(buildOverlay);
  199. }
  200. Widget buildOverlay(BuildContext context) {
  201. final toolbar = ZefyrToolbar.of(context);
  202. final buttons = Row(
  203. children: <Widget>[
  204. SizedBox(width: 8.0),
  205. toolbar.buildButton(context, ZefyrToolbarAction.headingLevel1),
  206. toolbar.buildButton(context, ZefyrToolbarAction.headingLevel2),
  207. toolbar.buildButton(context, ZefyrToolbarAction.headingLevel3),
  208. ],
  209. );
  210. return ZefyrToolbarScaffold(body: buttons);
  211. }
  212. }
  213. /// Controls image attribute.
  214. ///
  215. /// When pressed, this button displays overlay toolbar with three
  216. /// buttons for each heading level.
  217. class ImageButton extends StatefulWidget {
  218. const ImageButton({Key key}) : super(key: key);
  219. @override
  220. _ImageButtonState createState() => _ImageButtonState();
  221. }
  222. class _ImageButtonState extends State<ImageButton> {
  223. @override
  224. Widget build(BuildContext context) {
  225. final toolbar = ZefyrToolbar.of(context);
  226. return toolbar.buildButton(
  227. context,
  228. ZefyrToolbarAction.image,
  229. onPressed: showOverlay,
  230. );
  231. }
  232. void showOverlay() {
  233. final toolbar = ZefyrToolbar.of(context);
  234. toolbar.showOverlay(buildOverlay);
  235. }
  236. Widget buildOverlay(BuildContext context) {
  237. final toolbar = ZefyrToolbar.of(context);
  238. final buttons = Row(
  239. children: <Widget>[
  240. SizedBox(width: 8.0),
  241. toolbar.buildButton(context, ZefyrToolbarAction.cameraImage,
  242. onPressed: _pickFromCamera),
  243. toolbar.buildButton(context, ZefyrToolbarAction.galleryImage,
  244. onPressed: _pickFromGallery),
  245. ],
  246. );
  247. return ZefyrToolbarScaffold(body: buttons);
  248. }
  249. void _pickFromCamera() async {
  250. final editor = ZefyrToolbar.of(context).editor;
  251. final image =
  252. await editor.imageDelegate.pickImage(editor.imageDelegate.cameraSource);
  253. if (image != null) {
  254. editor.formatSelection(NotusAttribute.embed.image(image));
  255. }
  256. }
  257. void _pickFromGallery() async {
  258. final editor = ZefyrToolbar.of(context).editor;
  259. final image = await editor.imageDelegate
  260. .pickImage(editor.imageDelegate.gallerySource);
  261. if (image != null) {
  262. editor.formatSelection(NotusAttribute.embed.image(image));
  263. }
  264. }
  265. }
  266. class LinkButton extends StatefulWidget {
  267. const LinkButton({Key key}) : super(key: key);
  268. @override
  269. _LinkButtonState createState() => _LinkButtonState();
  270. }
  271. class _LinkButtonState extends State<LinkButton> {
  272. final TextEditingController _inputController = TextEditingController();
  273. Key _inputKey;
  274. bool _formatError = false;
  275. bool get isEditing => _inputKey != null;
  276. @override
  277. Widget build(BuildContext context) {
  278. final toolbar = ZefyrToolbar.of(context);
  279. final editor = toolbar.editor;
  280. final enabled =
  281. hasLink(editor.selectionStyle) || !editor.selection.isCollapsed;
  282. return toolbar.buildButton(
  283. context,
  284. ZefyrToolbarAction.link,
  285. onPressed: enabled ? showOverlay : null,
  286. );
  287. }
  288. bool hasLink(NotusStyle style) => style.contains(NotusAttribute.link);
  289. String getLink([String defaultValue]) {
  290. final editor = ZefyrToolbar.of(context).editor;
  291. final attrs = editor.selectionStyle;
  292. if (hasLink(attrs)) {
  293. return attrs.value(NotusAttribute.link);
  294. }
  295. return defaultValue;
  296. }
  297. void showOverlay() {
  298. final toolbar = ZefyrToolbar.of(context);
  299. toolbar.showOverlay(buildOverlay).whenComplete(cancelEdit);
  300. }
  301. void closeOverlay() {
  302. final toolbar = ZefyrToolbar.of(context);
  303. toolbar.closeOverlay();
  304. }
  305. void edit() {
  306. final toolbar = ZefyrToolbar.of(context);
  307. setState(() {
  308. _inputKey = UniqueKey();
  309. _inputController.text = getLink('https://');
  310. _inputController.addListener(_handleInputChange);
  311. toolbar.markNeedsRebuild();
  312. });
  313. }
  314. void doneEdit() {
  315. final toolbar = ZefyrToolbar.of(context);
  316. setState(() {
  317. var error = false;
  318. if (_inputController.text.isNotEmpty) {
  319. try {
  320. var uri = Uri.parse(_inputController.text);
  321. if ((uri.isScheme('https') || uri.isScheme('http')) &&
  322. uri.host.isNotEmpty) {
  323. toolbar.editor.formatSelection(
  324. NotusAttribute.link.fromString(_inputController.text));
  325. } else {
  326. error = true;
  327. }
  328. } on FormatException {
  329. error = true;
  330. }
  331. }
  332. if (error) {
  333. _formatError = error;
  334. toolbar.markNeedsRebuild();
  335. } else {
  336. _inputKey = null;
  337. _inputController.text = '';
  338. _inputController.removeListener(_handleInputChange);
  339. toolbar.markNeedsRebuild();
  340. toolbar.editor.focus();
  341. }
  342. });
  343. }
  344. void cancelEdit() {
  345. if (mounted) {
  346. final editor = ZefyrToolbar.of(context).editor;
  347. setState(() {
  348. _inputKey = null;
  349. _inputController.text = '';
  350. _inputController.removeListener(_handleInputChange);
  351. editor.focus();
  352. });
  353. }
  354. }
  355. void unlink() {
  356. final editor = ZefyrToolbar.of(context).editor;
  357. editor.formatSelection(NotusAttribute.link.unset);
  358. closeOverlay();
  359. }
  360. void copyToClipboard() {
  361. var link = getLink();
  362. assert(link != null);
  363. Clipboard.setData(ClipboardData(text: link));
  364. }
  365. void openInBrowser() async {
  366. final editor = ZefyrToolbar.of(context).editor;
  367. var link = getLink();
  368. assert(link != null);
  369. if (await canLaunch(link)) {
  370. editor.hideKeyboard();
  371. await launch(link, forceWebView: true);
  372. }
  373. }
  374. void _handleInputChange() {
  375. final toolbar = ZefyrToolbar.of(context);
  376. setState(() {
  377. _formatError = false;
  378. toolbar.markNeedsRebuild();
  379. });
  380. }
  381. Widget buildOverlay(BuildContext context) {
  382. final toolbar = ZefyrToolbar.of(context);
  383. final style = toolbar.editor.selectionStyle;
  384. String value = 'Tap to edit link';
  385. if (style.contains(NotusAttribute.link)) {
  386. value = style.value(NotusAttribute.link);
  387. }
  388. final clipboardEnabled = value != 'Tap to edit link';
  389. final body = !isEditing
  390. ? _LinkView(value: value, onTap: edit)
  391. : _LinkInput(
  392. key: _inputKey,
  393. controller: _inputController,
  394. formatError: _formatError,
  395. );
  396. final items = <Widget>[Expanded(child: body)];
  397. if (!isEditing) {
  398. final unlinkHandler = hasLink(style) ? unlink : null;
  399. final copyHandler = clipboardEnabled ? copyToClipboard : null;
  400. final openHandler = hasLink(style) ? openInBrowser : null;
  401. final buttons = <Widget>[
  402. toolbar.buildButton(context, ZefyrToolbarAction.unlink,
  403. onPressed: unlinkHandler),
  404. toolbar.buildButton(context, ZefyrToolbarAction.clipboardCopy,
  405. onPressed: copyHandler),
  406. toolbar.buildButton(
  407. context,
  408. ZefyrToolbarAction.openInBrowser,
  409. onPressed: openHandler,
  410. ),
  411. ];
  412. items.addAll(buttons);
  413. }
  414. final trailingPressed = isEditing ? doneEdit : closeOverlay;
  415. final trailingAction =
  416. isEditing ? ZefyrToolbarAction.confirm : ZefyrToolbarAction.close;
  417. return ZefyrToolbarScaffold(
  418. body: Row(children: items),
  419. trailing: toolbar.buildButton(
  420. context,
  421. trailingAction,
  422. onPressed: trailingPressed,
  423. ),
  424. );
  425. }
  426. }
  427. class _LinkInput extends StatefulWidget {
  428. final TextEditingController controller;
  429. final bool formatError;
  430. const _LinkInput(
  431. {Key key, @required this.controller, this.formatError = false})
  432. : super(key: key);
  433. @override
  434. _LinkInputState createState() {
  435. return _LinkInputState();
  436. }
  437. }
  438. class _LinkInputState extends State<_LinkInput> {
  439. final FocusNode _focusNode = FocusNode();
  440. ZefyrScope _editor;
  441. bool _didAutoFocus = false;
  442. @override
  443. void didChangeDependencies() {
  444. super.didChangeDependencies();
  445. if (!_didAutoFocus) {
  446. FocusScope.of(context).requestFocus(_focusNode);
  447. _didAutoFocus = true;
  448. }
  449. final toolbar = ZefyrToolbar.of(context);
  450. if (_editor != toolbar.editor) {
  451. _editor?.toolbarFocusNode = null;
  452. _editor = toolbar.editor;
  453. _editor.toolbarFocusNode = _focusNode;
  454. }
  455. }
  456. @override
  457. void dispose() {
  458. _editor?.toolbarFocusNode = null;
  459. _focusNode.dispose();
  460. _editor = null;
  461. super.dispose();
  462. }
  463. @override
  464. Widget build(BuildContext context) {
  465. final theme = Theme.of(context);
  466. final toolbarTheme = ZefyrTheme.of(context).toolbarTheme;
  467. final color =
  468. widget.formatError ? Colors.redAccent : toolbarTheme.iconColor;
  469. final style = theme.textTheme.subhead.copyWith(color: color);
  470. return TextField(
  471. style: style,
  472. keyboardType: TextInputType.url,
  473. focusNode: _focusNode,
  474. controller: widget.controller,
  475. autofocus: true,
  476. decoration: InputDecoration(
  477. hintText: 'https://',
  478. filled: true,
  479. fillColor: toolbarTheme.color,
  480. border: InputBorder.none,
  481. contentPadding: const EdgeInsets.all(10.0),
  482. ),
  483. );
  484. }
  485. }
  486. class _LinkView extends StatelessWidget {
  487. const _LinkView({Key key, @required this.value, this.onTap})
  488. : super(key: key);
  489. final String value;
  490. final VoidCallback onTap;
  491. @override
  492. Widget build(BuildContext context) {
  493. final theme = Theme.of(context);
  494. final toolbarTheme = ZefyrTheme.of(context).toolbarTheme;
  495. Widget widget = ClipRect(
  496. child: ListView(
  497. scrollDirection: Axis.horizontal,
  498. children: <Widget>[
  499. Container(
  500. alignment: AlignmentDirectional.centerStart,
  501. constraints: BoxConstraints(minHeight: ZefyrToolbar.kToolbarHeight),
  502. padding: const EdgeInsets.all(10.0),
  503. child: Text(
  504. value,
  505. maxLines: 1,
  506. overflow: TextOverflow.ellipsis,
  507. style: theme.textTheme.subhead
  508. .copyWith(color: toolbarTheme.disabledIconColor),
  509. ),
  510. )
  511. ],
  512. ),
  513. );
  514. if (onTap != null) {
  515. widget = GestureDetector(
  516. child: widget,
  517. onTap: onTap,
  518. );
  519. }
  520. return widget;
  521. }
  522. }