Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: Support the mobile platforms (iOS and Android) and make the editor more extensible. #97

Closed
2 tasks done
LucasXu0 opened this issue Apr 24, 2023 · 3 comments
Closed
2 tasks done
Labels
editor features related to the rich-text editor mobile features related to mobile new feature this is something new for the end user

Comments

@LucasXu0
Copy link
Collaborator

LucasXu0 commented Apr 24, 2023

Hi, All. I'm refactoring the rendered part for appflowy_editor to support the mobile platform. Here's an update for the job.

Major refactor

  • keep the services structure as it is, but split them into two parts: mobile service and desktop service.

    • selection service
      • remove the currenctSelection and currentSelectedNodes from it, and move this functionality to the editor_state. The selection is no longer dependent on the UI, making it more testable.
      • mobile: support tap to select collapsed position only. (TODO: support drag selection)
      • desktop: same as it is now, click and drag.
    • scroll service
      • remove the Listen used to update the document's offset and replace it with SingleChildScrollView.
      • Implement auto scrolling using the EdgeDraggingAutoScroller (TODO: optimize the effect as it is not currently satisfactory).
    • keyboard service
      • the keyboard service is responsible for handling events from both software keyboard(IME) and hardware keyboard now.
      • the shortcut event is divided into two parts:
        • the CharacterShortcutEvent is based on a single character and used in delta_input_service.dart(Software keyboard, IME), like /, , and so on.
        • the CommandShortcutEvent is based on a series of commands and used in keyboard_service.dart(Hardware keyboard), like cmd+c, ctrl+c, and so on.
    • Discard the input service
      • combine this part into keyboard service.
  • Replace the NodeWidgetBuilder with BlockComponentBuilder.

abstract class BlockComponentBuilder {
  /// validate the node.
  ///
  /// return true if the node is valid.
  /// return false if the node is invalid,
  ///   and the node will be displayed as a PlaceHolder widget.
  bool validate(Node node) => true;

  Widget build(BlockComponentContext blockComponentContext);
}
  • Split the toolbar service into a separate directory and extract it into a independent package.

    • mobile: display the toolbar above the keyboard.
    • desktop: display the toolbar above the selection.
  • Move all of the built-in node builders to the 'block_component' folder and do not add these builders to the editor as default builders. Now, developers need to register them with the editor as needed.

customBuilders: {
  'paragraph': TextBlockComponentBuilder(),
  'todo_list': TodoListBlockComponentBuilder(),
  'bulleted_list': BulletedListBlockComponentBuilder(),
  'numbered_list': NumberedListBlockComponentBuilder(),
  'quote': QuoteBlockComponentBuilder(),
},
  • Remove the TextNode and add the delta attribute to the Node instead.
{
  "type": "paragraph",
  "attributes": {
    "delta": [
      { "insert": "👋 " },
      { "insert": "Welcome to", "attributes": { "bold": true } },
      { "insert": " " },
      {
        "insert": "AppFlowy Editor",
        "attributes": {
          "href": "appflowy.io",
          "italic": true,
          "bold": true
        }
      }
    ]
  }
},

Directory structure

Screenshot 2023-04-24 at 12 57 18

Tasks

@LucasXu0 LucasXu0 changed the title Placeholder WIP: Support the mobile platforms (iOS and Android) and make the editor more extensible. Apr 24, 2023
@LucasXu0
Copy link
Collaborator Author

LucasXu0 commented Apr 25, 2023

Shortcuts Migration

Previously, both character and command events were defined in the same file 'shortcut_event.dart'. However, we split them into two files to support the mobile platform as we cannot listen to keyboard events on mobile devices. Therefore, we use IME to trigger shortcuts based on a character, such as underscore '_' or asterisk '*', and use Focus to trigger shortcuts based on a command.

Character shortcut events

To define shortcuts based on a specific block component, it's recommended to define them in the same folder as the block component. For instance, if there is a bulleted_list_block_component and we want to use - to change the selected node to a bulleted list style, we should define the shortcut in the same folder as the bulleted_list_block_component, as shown in the picture.
Screenshot 2023-04-25 at 10 03 14

On the other hand, for shortcuts that are not specific to any particular block component, such as using double asterisks ** to format text to bold, it is recommended to define them in the editor/editor_component/service/shortcuts/character_shortcut_events/ folder and export them in the character_shortcut_events file.

Steps

  1. define a CharacterShortcutEvent
// lib/src/editor/block_component/bulleted_list_block_component/bulleted_list_character_shortcut.dart
CharacterShortcutEvent formatAsteriskToBulletedList = CharacterShortcutEvent(
  key: 'format asterisk to bulleted list',
  character: ' ',
  handler: (editorState) async =>
      await _formatSymbolToBulletedList(editorState, '*'),
);
  1. write a test for it
      // test/new/block_component/bulleted_list_block_component/bulleted_list_shortcut_test.dart
      test(
          'mock inputting a ` ` after asterisk which is located at the front of the text',
          () async {
        const text = 'Welcome to AppFlowy Editor 🔥!';
        final document = Document.blank().combineParagraphs(
          1,
          builder: (index) => Delta()..insert('*$text'),
        );
        final editorState = EditorState(document: document);

        // *|Welcome to AppFlowy Editor 🔥!
        final selection = Selection.collapsed(
          Position(path: [0], offset: 1),
        );
        editorState.selection = selection;
        final result = await formatAsteriskToBulletedList.execute(editorState);

        expect(result, true);
        final after = editorState.getNodeAtPath([0])!;
        expect(after.delta!.toPlainText(), text);
        expect(after.type, 'bulleted_list');
      });
  1. register it into appflowy_editor.
  // example/lib/pages/simple_editor.dart
  Widget _buildEditor(BuildContext context, EditorState editorState) {
    return AppFlowyEditor(
      editorState: editorState,
      themeData: themeData,
      autoFocus: editorState.document.isEmpty,
      customBuilders: {
        'bulleted_list': BulletedListBlockComponentBuilder(),
      },
      characterShortcutEvents: [
        // bulleted list
        formatAsteriskToBulletedList,
        formatMinusToBulletedList,
      ],
    );
  }

to-do list

  • ⭐️ backtick to code: `abc` to abc.
  • ⭐️ tilde to strikethrough: ~abc~ to abc.
  • ⭐️ single asterisk or single underline to italic: *abc* to abc, _abc_ to abc.
  • ⭐️ double asterisk or double underline to bold: **abc** to abc, __abc__ to abc.
  • ⭐️ slash to menu: /
  • ⭐️ heading markdown syntax: # , ## , ..., #### .
  • ⭐️ quote markdown syntax: > .
  • ⭐️ checkbox markdown syntax: -[] or [] for the uncheck status, -[x] or [x] for the check status.
  • ⭐️ bulleted list markdown syntax: - or *
  • ⭐️ numbered list markdown syntax: 1. ...

@annieappflowy annieappflowy added new feature this is something new for the end user mobile features related to mobile editor features related to the rich-text editor labels Apr 25, 2023
@LucasXu0
Copy link
Collaborator Author

LucasXu0 commented Apr 28, 2023

Test migration

  • Ensure to call await editorState.dispose() when testing the editor in a widget test.
  • Mock the key press from hardware keyboard using simulateKeyDownEvent instead of pressLogicKey.

Reference

  • testable_editor.dart

@LucasXu0
Copy link
Collaborator Author

LucasXu0 commented May 10, 2023

How to define a new block component

using callout block as an example.

1. define the keys for your block

class CalloutBlockKeys {
  const CalloutBlockKeys._();

  static const String type = 'callout';

  /// The content of a code block.
  ///
  /// The value is a String.
  static const String delta = 'delta';

  /// The background color of a callout block.
  ///
  /// The value is a String.
  static const String backgroundColor = 'bgColor';

  /// The emoji icon of a callout block.
  ///
  /// The value is a String.
  static const String icon = 'icon';
}

2. define your node constructor

// creating a new callout node
Node calloutNode({
  Delta? delta,
  String emoji = '📌',
  String backgroundColor = '#F0F0F0',
}) {
  final attributes = {
    CalloutBlockKeys.delta: (delta ?? Delta()).toJson(),
    CalloutBlockKeys.icon: emoji,
    CalloutBlockKeys.backgroundColor: backgroundColor,
  };
  return Node(
    type: CalloutBlockKeys.type,
    attributes: attributes,
  );
}

3. define a component builder extended BlockComponentBuilder

3.1 implement the build function.
3.2 validate the data of the node, if the result is false, the node will be rendered as a placeholder .

class CalloutBlockComponentBuilder extends BlockComponentBuilder {
  const CalloutBlockComponentBuilder({
    this.configuration = const BlockComponentConfiguration(),
  });

  final BlockComponentConfiguration configuration;

  @override
  Widget build(BlockComponentContext blockComponentContext) {
    final node = blockComponentContext.node;
    return CalloutBlockComponentWidget(
      key: node.key,
      node: node,
      configuration: configuration,
    );
  }

  @override
  bool validate(Node node) =>
      node.delta != null &&
      node.children.isEmpty &&
      node.attributes[CalloutBlockKeys.icon] is String &&
      node.attributes[CalloutBlockKeys.backgroundColor] is String;
}

4. define your custom widget

4.1 define your own widget for rendering the callout node.
4.2 mixin with the SelectableMixin to make sure the widget can be selected.

// the main widget for rendering the callout block
class CalloutBlockComponentWidget extends StatefulWidget {
  const CalloutBlockComponentWidget({
    super.key,
    required this.node,
    required this.configuration,
  });

  final Node node;
  final BlockComponentConfiguration configuration;

  @override
  State<CalloutBlockComponentWidget> createState() =>
      _CalloutBlockComponentWidgetState();
}

class _CalloutBlockComponentWidgetState
    extends State<CalloutBlockComponentWidget>
    with SelectableMixin, DefaultSelectable, BlockComponentConfigurable {
// ...
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
editor features related to the rich-text editor mobile features related to mobile new feature this is something new for the end user
Projects
Status: Done
Development

Successfully merging a pull request may close this issue.

2 participants