visual-space / visual-editor

Rich text editor for Flutter based on Delta format (Quill fork)
MIT License
280 stars 44 forks source link

Bug - `type 'Null' is not a subtype of type 'BlockM' in type cast` when tapping after app is initialized #239

Closed LuchoTurtle closed 7 months ago

LuchoTurtle commented 11 months ago

Hello!

I'm currently migrating a demo app from flutter-quill to visual-editor (see more context in https://github.com/dwyl/flutter-wysiwyg-editor-tutorial/issues/3) using the branch from the https://github.com/visual-space/visual-editor/pull/237 (I'm basically using my fork of your repo while https://github.com/visual-space/visual-editor/pull/237 is not merged).

Bug 1 🐞

The app builds fine and inclusively works wonderfully on the web. However, in mobile devices, when I tap the editor to enter text, I get the following error:

════════ Exception caught by gesture ═══════════════════════════════════════════
The following _TypeError was thrown while handling a gesture:
type 'Null' is not a subtype of type 'BlockM' in type cast

The error comes from these lines of code:

https://github.com/visual-space/visual-editor/blob/3747d0e2f43cd88b253df766f201ecfc8045fee6/lib/document/controllers/document.controller.dart#L412-L423

I don't know how to solve this issue, though. queryChild is invoked when I tap up after the app initializes and I get this error. Because there is no text, the nodePos.node is null and it fails to cast to LineM. I don't know, however, what I should return if the nodePos.node is null.

Bug 2 🐞

In addition to this, when first selecting text to cut/copy or paste, I get the following error on:

════════ Exception caught by scheduler library ═════════════════════════════════
The following _TypeError was thrown during a scheduler callback:
Null check operator used on a null value

When the exception was thrown, this was the stack
#0      SelectionHandlesService.showToolbar

This happens in these lines of code, apparently.

https://github.com/visual-space/visual-editor/blob/3747d0e2f43cd88b253df766f201ecfc8045fee6/lib/selection/services/selection-handles.service.dart#L132-L149

How do I replicate these issues?

To replicate this issue, you can create a new Flutter project, and have two files on lib:

main.dart

import 'package:app/home_page.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:responsive_framework/responsive_framework.dart';

// coverage:ignore-start
void main() {
  WidgetsFlutterBinding.ensureInitialized();
  runApp(
    App(
      platformService: PlatformService(),
    ),
  );
}
// coverage:ignore-end

/// Entry gateway to the application.
/// Defining the MaterialApp attributes and Responsive Framework breakpoints.
class App extends StatelessWidget {
  const App({required this.platformService, super.key});

  final PlatformService platformService;

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Editor Demo',
      builder: (context, child) => ResponsiveBreakpoints.builder(
        child: child!,
        breakpoints: [
          const Breakpoint(start: 0, end: 425, name: MOBILE),
          const Breakpoint(start: 426, end: 768, name: TABLET),
          const Breakpoint(start: 769, end: 1440, name: DESKTOP),
          const Breakpoint(start: 1441, end: double.infinity, name: '4K'),
        ],
      ),
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.white),
        useMaterial3: true,
      ),
      home: HomePage(platformService: platformService),
    );
  }
}

// coverage:ignore-start
/// Platform service class that tells if the platform is web-based or not
class PlatformService {
  bool isWebPlatform() {
    return kIsWeb;
  }
}
// coverage:ignore-end

home_page.dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:ui';

import 'package:app/main.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:visual_editor/document/models/attributes/attributes.model.dart';
import 'package:visual_editor/visual-editor.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:http/http.dart' as http;
import 'package:mime/mime.dart';
import 'package:http_parser/http_parser.dart';

const quillEditorKey = Key('quillEditorKey');

/// Home page with the `flutter-quill` editor
class HomePage extends StatefulWidget {
  const HomePage({
    required this.platformService,
    super.key,
  });

  final PlatformService platformService;

  @override
  HomePageState createState() => HomePageState();
}

class HomePageState extends State<HomePage> {
  /// `flutter-quill` editor controller
  EditorController? _controller;

  /// Focus node used to obtain keyboard focus and events
  final FocusNode _focusNode = FocusNode();

  @override
  void initState() {
    super.initState();
    _initializeText();
  }

  /// Initializing the [Delta](https://quilljs.com/docs/delta/) document with sample text.
  Future<void> _initializeText() async {
    // final doc = Document()..insert(0, 'Just a friendly empty text :)');
    final doc = DeltaDocM();
    setState(() {
      _controller = EditorController(
        document: doc,
      );
    });
  }

  @override
  Widget build(BuildContext context) {
    /// Loading widget if controller's not loaded
    if (_controller == null) {
      return const Scaffold(body: Center(child: Text('Loading...')));
    }

    /// Returning scaffold with editor as body
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Colors.white,
        elevation: 0,
        centerTitle: false,
        title: const Text(
          'Flutter Quill',
        ),
      ),
      body: _buildEditor(context),
    );
  }

  /// Build the `flutter-quill` editor to be shown on screen.
  Widget _buildEditor(BuildContext context) {
    // Default editor (for mobile devices)
    Widget quillEditor = VisualEditor(
      controller: _controller!,
      scrollController: ScrollController(),
      focusNode: _focusNode,
      config: EditorConfigM(
        scrollable: true,
        autoFocus: false,
        readOnly: false,
        placeholder: 'Write what\'s on your mind.',
        enableInteractiveSelection: true,
        expands: false,
        padding: EdgeInsets.zero,
        customStyles: const EditorStylesM(
          h1: TextBlockStyleM(
            TextStyle(
              fontSize: 32,
              color: Colors.black,
              height: 1.15,
              fontWeight: FontWeight.w300,
            ),
            VerticalSpacing(top: 16, bottom: 0),
            VerticalSpacing(top: 0, bottom: 0),
            VerticalSpacing(top: 16, bottom: 0),
            null,
          ),
          sizeSmall: TextStyle(fontSize: 9),
        ),
      ),
    );

    // Alternatively, the web editor version is shown  (with the web embeds)
    if (widget.platformService.isWebPlatform()) {
      quillEditor = VisualEditor(
        controller: _controller!,
        scrollController: ScrollController(),
        focusNode: _focusNode,
        config: EditorConfigM(
          scrollable: true,
          enableInteractiveSelection: false,
          autoFocus: false,
          readOnly: false,
          placeholder: 'Add content',
          expands: false,
          padding: EdgeInsets.zero,
          customStyles: const EditorStylesM(
            h1: TextBlockStyleM(
              TextStyle(
                fontSize: 32,
                color: Colors.black,
                height: 1.15,
                fontWeight: FontWeight.w300,
              ),
              VerticalSpacing(top: 16, bottom: 0),
              VerticalSpacing(top: 0, bottom: 0),
              VerticalSpacing(top: 16, bottom: 0),
              null,
            ),
            sizeSmall: TextStyle(fontSize: 9),
          ),
        ),
      );
    }

    // Toolbar definitions
    const toolbarIconSize = 18.0;
    const toolbarButtonSpacing = 2.5;

    // Instantiating the toolbar
    final toolbar = EditorToolbar(
      children: [
        HistoryButton(
          buttonsSpacing: toolbarButtonSpacing,
          icon: Icons.undo_outlined,
          iconSize: toolbarIconSize,
          controller: _controller!,
          isUndo: true,
        ),
        HistoryButton(
          buttonsSpacing: toolbarButtonSpacing,
          icon: Icons.redo_outlined,
          iconSize: toolbarIconSize,
          controller: _controller!,
          isUndo: false,
        ),
        ToggleStyleButton(
          buttonsSpacing: toolbarButtonSpacing,
          attribute: AttributesM.bold,
          icon: Icons.format_bold,
          iconSize: toolbarIconSize,
          controller: _controller!,
        ),
        ToggleStyleButton(
          buttonsSpacing: toolbarButtonSpacing,
          attribute: AttributesM.italic,
          icon: Icons.format_italic,
          iconSize: toolbarIconSize,
          controller: _controller!,
        ),
        ToggleStyleButton(
          buttonsSpacing: toolbarButtonSpacing,
          attribute: AttributesM.underline,
          icon: Icons.format_underline,
          iconSize: toolbarIconSize,
          controller: _controller!,
        ),
        ToggleStyleButton(
          buttonsSpacing: toolbarButtonSpacing,
          attribute: AttributesM.strikeThrough,
          icon: Icons.format_strikethrough,
          iconSize: toolbarIconSize,
          controller: _controller!,
        ),

        // Our embed buttons
        ImageButton(
          icon: Icons.image,
          iconSize: toolbarIconSize,
          buttonsSpacing: toolbarButtonSpacing,
          controller: _controller!,
          onImagePickCallback: _onImagePickCallback,
          webImagePickImpl: _webImagePickImpl,
          mediaPickSettingSelector: (context) {
            return Future.value(MediaPickSettingE.Gallery);
          },
        )
      ],
    );

    // Rendering the final editor + toolbar
    return SafeArea(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        children: <Widget>[
          Expanded(
            flex: 15,
            child: Container(
              key: quillEditorKey,
              color: Colors.white,
              padding: const EdgeInsets.only(left: 16, right: 16),
              child: quillEditor,
            ),
          ),
          Container(child: toolbar),
        ],
      ),
    );
  }

  /// Renders the image picked by imagePicker from local file storage
  /// You can also upload the picked image to any server (eg : AWS s3
  /// or Firebase) and then return the uploaded image URL.
  ///
  /// It's only called on mobile platforms.
  Future<String> _onImagePickCallback(File file) async {
    final appDocDir = await getApplicationDocumentsDirectory();
    final copiedFile = await file.copy('${appDocDir.path}/${basename(file.path)}');
    return copiedFile.path.toString();
  }

  /// Callback that is called after an image is picked whilst on the web platform.
  /// Returns the URL of the image.
  /// Returns null if an error occurred uploading the file or the image was not picked.
  Future<String?> _webImagePickImpl(OnImagePickCallback onImagePickCallback) async {
    // Lets the user pick one file; files with any file extension can be selected
    final result = await ImageFilePicker().pickImage();

    // The result will be null, if the user aborted the dialog
    if (result == null || result.files.isEmpty) {
      return null;
    }

    // Read file as bytes (https://github.com/miguelpruivo/flutter_file_picker/wiki/FAQ#q-how-do-i-access-the-path-on-web)
    final platformFile = result.files.first;
    final bytes = platformFile.bytes;

    if (bytes == null) {
      return null;
    }

    // Make HTTP request to upload the image to the file
    const apiURL = 'https://imgup.fly.dev/api/images';
    final request = http.MultipartRequest('POST', Uri.parse(apiURL));

    final httpImage = http.MultipartFile.fromBytes(
      'image',
      bytes,
      contentType: MediaType.parse(lookupMimeType('', headerBytes: bytes)!),
      filename: platformFile.name,
    );
    request.files.add(httpImage);

    // Check the response and handle accordingly
    return http.Client().send(request).then((response) async {
      if (response.statusCode != 200) {
        return null;
      }

      final responseStream = await http.Response.fromStream(response);
      final responseData = json.decode(responseStream.body);
      return responseData['url'];
    });
  }
}

// coverage:ignore-start
/// Image file picker wrapper class
class ImageFilePicker {
  Future<FilePickerResult?> pickImage() => FilePicker.platform.pickFiles(type: FileType.image);
}
// coverage:ignore-end

And here's the pubspec.yaml file.

name: app
description: A new Flutter project.
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev

# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1

environment:
  sdk: '>=3.0.5 <4.0.0'

# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
  flutter:
    sdk: flutter

  file_picker: ^5.3.3
  universal_io: ^2.2.2
  responsive_framework: ^1.1.0
  universal_html: ^2.2.3
  path: ^1.8.3
  path_provider: ^2.1.0
  http: ^1.1.0
  mime: ^1.0.4
  http_parser: ^4.0.2

  visual_editor:
    git:
      url: https://github.com/LuchoTurtle/visual-editor.git
      ref: update_dependencies#236

dev_dependencies:
  integration_test:
    sdk: flutter
  flutter_test:
    sdk: flutter

  # The "flutter_lints" package below contains a set of recommended lints to
  # encourage good coding practices. The lint set provided by the package is
  # activated in the `analysis_options.yaml` file located at the root of your
  # package. See that file for information about deactivating specific lint
  # rules and activating additional ones.
  flutter_lints: ^2.0.0
  mockito: ^5.4.2
  build_runner: ^2.4.5
  cross_file: ^0.3.3+5

# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec

# The following section is specific to Flutter packages.
flutter:

  # The following line ensures that the Material Icons font is
  # included with your application, so that you can use the icons in
  # the material Icons class.
  uses-material-design: true

Make sure to run on a real device. I've tested this on different devices and, when running my unit tests, this error pops up as well. This is why I know this is not related to my phone.

adrian-moisa commented 11 months ago

Oh wow, that's a highly detailed report. Much appreciated. Let me have a look and I'll get back to you. But first, what's the time pressure on them? This weekend me and the entire are offline. So I'll be able to handle it next week if it's not super urgent.

LuchoTurtle commented 11 months ago

It's not urgent. It works if I'm not debugging (because the debugger doesn't hang on the line), but the tests do not run. If you can see it in the coming week, that'd be cool :)

adrian-moisa commented 11 months ago

Btw, do you have some time to meet on discord? It'd be nice to exchange some words just to be in sync in planning.

LuchoTurtle commented 11 months ago

Sure, we can set up a time and we can have a chat :). Though the PR's I've opened can surely be merged in the order they were created, so I'm not sure what more planning there needs to be πŸ˜…

adrian-moisa commented 11 months ago

Just ping me when you happen to have time. Unfortunately I work in a very umpredictable time environment. Usually I'm available online and I can jump in. 20 mins should suffice.

adrian-moisa commented 7 months ago

Issues were fixed in separate commit