Skip to content

Commit 0d5a07c

Browse files
[Super Editor Clipboard] - Created an iOS plugin that intercepts Flutter's paste command from iOS and re-routes it to the app (#2897)
* [Super Editor] - Add a BitmapImageNode to display in-memory bitmaps
1 parent d39cc10 commit 0d5a07c

File tree

65 files changed

+3478
-7
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

65 files changed

+3478
-7
lines changed

super_editor/lib/src/default_editor/image.dart

Lines changed: 287 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import 'dart:typed_data' show Uint8List;
2+
13
import 'package:attributed_text/attributed_text.dart';
24
import 'package:flutter/material.dart';
35
import 'package:super_editor/src/default_editor/layout_single_column/selection_aware_viewmodel.dart';
@@ -80,7 +82,6 @@ class ImageNode extends BlockNode {
8082
);
8183
}
8284

83-
@override
8485
ImageNode copy() {
8586
return ImageNode(
8687
id: id,
@@ -298,3 +299,288 @@ class ExpectedSize {
298299
@override
299300
int get hashCode => width.hashCode ^ height.hashCode;
300301
}
302+
303+
/// A [ComponentBuilder] that builds [BitmapImageComponent]s based on [BitmapImageNode]s.
304+
///
305+
/// These nodes and components work specifically with bitmap images, whose data is available in-memory
306+
/// as a [Uint8List].
307+
class BitmapImageComponentBuilder implements ComponentBuilder {
308+
const BitmapImageComponentBuilder();
309+
310+
@override
311+
SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) {
312+
if (node is! BitmapImageNode) {
313+
return null;
314+
}
315+
316+
return BitmapImageComponentViewModel(
317+
nodeId: node.id,
318+
createdAt: node.metadata[NodeMetadata.createdAt],
319+
imageData: node.imageData,
320+
expectedSize: node.expectedBitmapSize,
321+
selectionColor: const Color(0x00000000),
322+
);
323+
}
324+
325+
@override
326+
Widget? createComponent(
327+
SingleColumnDocumentComponentContext componentContext,
328+
SingleColumnLayoutComponentViewModel componentViewModel,
329+
) {
330+
if (componentViewModel is! BitmapImageComponentViewModel) {
331+
return null;
332+
}
333+
334+
return BitmapImageComponent(
335+
componentKey: componentContext.componentKey,
336+
imageData: componentViewModel.imageData,
337+
expectedSize: componentViewModel.expectedSize,
338+
selection: componentViewModel.selection?.nodeSelection as UpstreamDownstreamNodeSelection?,
339+
selectionColor: componentViewModel.selectionColor,
340+
opacity: componentViewModel.opacity,
341+
);
342+
}
343+
}
344+
345+
class BitmapImageComponentViewModel extends SingleColumnLayoutComponentViewModel with SelectionAwareViewModelMixin {
346+
BitmapImageComponentViewModel({
347+
required super.nodeId,
348+
super.createdAt,
349+
super.maxWidth,
350+
super.padding = EdgeInsets.zero,
351+
super.opacity = 1.0,
352+
required this.imageData,
353+
this.expectedSize,
354+
DocumentNodeSelection? selection,
355+
Color selectionColor = Colors.transparent,
356+
}) {
357+
this.selection = selection;
358+
this.selectionColor = selectionColor;
359+
}
360+
361+
Uint8List imageData;
362+
ExpectedSize? expectedSize;
363+
364+
@override
365+
BitmapImageComponentViewModel copy() {
366+
return BitmapImageComponentViewModel(
367+
nodeId: nodeId,
368+
createdAt: createdAt,
369+
maxWidth: maxWidth,
370+
padding: padding,
371+
opacity: opacity,
372+
imageData: imageData,
373+
expectedSize: expectedSize,
374+
selection: selection,
375+
selectionColor: selectionColor,
376+
);
377+
}
378+
379+
@override
380+
bool operator ==(Object other) =>
381+
identical(this, other) ||
382+
super == other &&
383+
other is BitmapImageComponentViewModel &&
384+
runtimeType == other.runtimeType &&
385+
nodeId == other.nodeId &&
386+
createdAt == other.createdAt &&
387+
selection == other.selection &&
388+
selectionColor == other.selectionColor &&
389+
imageData.isSameAs(other.imageData);
390+
391+
@override
392+
int get hashCode =>
393+
super.hashCode ^
394+
nodeId.hashCode ^
395+
createdAt.hashCode ^
396+
imageData.hashCode ^
397+
selection.hashCode ^
398+
selectionColor.hashCode;
399+
}
400+
401+
/// Displays an image in a document.
402+
class BitmapImageComponent extends StatelessWidget {
403+
const BitmapImageComponent({
404+
super.key,
405+
required this.componentKey,
406+
required this.imageData,
407+
this.expectedSize,
408+
this.selectionColor = Colors.blue,
409+
this.selection,
410+
this.opacity = 1.0,
411+
});
412+
413+
final GlobalKey componentKey;
414+
final Uint8List imageData;
415+
final ExpectedSize? expectedSize;
416+
final Color selectionColor;
417+
final UpstreamDownstreamNodeSelection? selection;
418+
419+
final double opacity;
420+
421+
@override
422+
Widget build(BuildContext context) {
423+
return MouseRegion(
424+
cursor: SystemMouseCursors.basic,
425+
hitTestBehavior: HitTestBehavior.translucent,
426+
child: IgnorePointer(
427+
child: Center(
428+
child: SelectableBox(
429+
selection: selection,
430+
selectionColor: selectionColor,
431+
child: BoxComponent(
432+
key: componentKey,
433+
opacity: opacity,
434+
child: Image.memory(
435+
imageData,
436+
fit: BoxFit.contain,
437+
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
438+
if (frame != null) {
439+
// The image is already loaded. Use the image as is.
440+
return child;
441+
}
442+
443+
if (expectedSize != null && expectedSize!.width != null && expectedSize!.height != null) {
444+
// Both width and height were provide.
445+
// Preserve the aspect ratio of the original image.
446+
return AspectRatio(
447+
aspectRatio: expectedSize!.aspectRatio,
448+
child: SizedBox(width: expectedSize!.width!.toDouble(), height: expectedSize!.height!.toDouble()),
449+
);
450+
}
451+
452+
// The image is still loading and only one dimension was provided.
453+
// Use the given dimension.
454+
return SizedBox(width: expectedSize?.width?.toDouble(), height: expectedSize?.height?.toDouble());
455+
},
456+
),
457+
),
458+
),
459+
),
460+
),
461+
);
462+
}
463+
}
464+
465+
/// [DocumentNode] that represents an image at a URL.
466+
@immutable
467+
class BitmapImageNode extends BlockNode {
468+
BitmapImageNode({
469+
required this.id,
470+
required this.imageData,
471+
this.expectedBitmapSize,
472+
this.altText = '',
473+
super.metadata,
474+
}) {
475+
initAddToMetadata({NodeMetadata.blockType: const NamedAttribution("image")});
476+
}
477+
478+
@override
479+
final String id;
480+
481+
final Uint8List imageData;
482+
483+
/// The expected size of the image.
484+
///
485+
/// Used to size the component while the image is still being loaded,
486+
/// so the content don't shift after the image is loaded.
487+
///
488+
/// It's technically permissible to provide only a single expected dimension,
489+
/// however providing only a single dimension won't provide enough information
490+
/// to size an image component before the image is loaded. Providing only a
491+
/// width in a vertical layout won't have any visual effect. Providing only a height
492+
/// in a vertical layout will likely take up more space or less space than the final
493+
/// image because the final image will probably be scaled. Therefore, to take
494+
/// advantage of [ExpectedSize], you should try to provide both dimensions.
495+
final ExpectedSize? expectedBitmapSize;
496+
497+
final String altText;
498+
499+
@override
500+
String? copyContent(dynamic selection) {
501+
// There's no obvious String serialization for a bitmap image.
502+
return null;
503+
}
504+
505+
@override
506+
bool hasEquivalentContent(DocumentNode other) {
507+
return other is BitmapImageNode && altText == other.altText && imageData.isSameAs(other.imageData);
508+
}
509+
510+
@override
511+
DocumentNode copyWithAddedMetadata(Map<String, dynamic> newProperties) {
512+
return BitmapImageNode(
513+
id: id,
514+
imageData: imageData,
515+
expectedBitmapSize: expectedBitmapSize,
516+
altText: altText,
517+
metadata: {...metadata, ...newProperties},
518+
);
519+
}
520+
521+
@override
522+
DocumentNode copyAndReplaceMetadata(Map<String, dynamic> newMetadata) {
523+
return BitmapImageNode(
524+
id: id,
525+
imageData: imageData,
526+
expectedBitmapSize: expectedBitmapSize,
527+
altText: altText,
528+
metadata: newMetadata,
529+
);
530+
}
531+
532+
BitmapImageNode copy() {
533+
return BitmapImageNode(
534+
id: id,
535+
imageData: imageData,
536+
expectedBitmapSize: expectedBitmapSize,
537+
altText: altText,
538+
metadata: Map.from(metadata),
539+
);
540+
}
541+
542+
@override
543+
bool operator ==(Object other) =>
544+
identical(this, other) ||
545+
other is BitmapImageNode &&
546+
runtimeType == other.runtimeType &&
547+
id == other.id &&
548+
altText == other.altText &&
549+
imageData.isSameAs(other.imageData);
550+
551+
@override
552+
int get hashCode => id.hashCode ^ imageData.hashCode ^ altText.hashCode;
553+
}
554+
555+
extension on Uint8List {
556+
/// Returns `true` if this [Uint8List] is identical in data to [other].
557+
bool isSameAs(Uint8List other) {
558+
if (identical(this, other)) {
559+
return true;
560+
}
561+
562+
if (this.lengthInBytes != other.lengthInBytes) {
563+
return false;
564+
}
565+
566+
// Treat the underlying buffer as a list of 64-bit integers
567+
// to compare 8 bytes at a time.
568+
final words1 = buffer.asUint64List();
569+
final words2 = other.buffer.asUint64List();
570+
571+
for (var i = 0; i < words1.length; i++) {
572+
if (words1[i] != words2[i]) {
573+
return false;
574+
}
575+
}
576+
577+
// Compare any remaining bytes (if length wasn't a multiple of 8)
578+
for (var i = words1.lengthInBytes; i < lengthInBytes; i++) {
579+
if (this[i] != other[i]) {
580+
return false;
581+
}
582+
}
583+
584+
return true;
585+
}
586+
}

super_editor/lib/src/default_editor/super_editor.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1428,6 +1428,7 @@ const defaultComponentBuilders = <ComponentBuilder>[
14281428
BlockquoteComponentBuilder(),
14291429
ParagraphComponentBuilder(),
14301430
ListItemComponentBuilder(),
1431+
BitmapImageComponentBuilder(),
14311432
ImageComponentBuilder(),
14321433
HorizontalRuleComponentBuilder(),
14331434
];

super_editor_clipboard/.metadata

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,27 @@
44
# This file should be version controlled and should not be manually edited.
55

66
version:
7-
revision: "8defaa71a77c16e8547abdbfad2053ce3a6e2d5b"
7+
revision: "19074d12f7eaf6a8180cd4036a430c1d76de904e"
88
channel: "stable"
99

10-
project_type: package
10+
project_type: plugin
11+
12+
# Tracks metadata for the flutter migrate command
13+
migration:
14+
platforms:
15+
- platform: root
16+
create_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e
17+
base_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e
18+
- platform: ios
19+
create_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e
20+
base_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e
21+
22+
# User provided section
23+
24+
# List of Local paths (relative to this file) that should be
25+
# ignored by the migrate tool.
26+
#
27+
# Files that are not part of the templates will be ignored by default.
28+
unmanaged_files:
29+
- 'lib/main.dart'
30+
- 'ios/Runner.xcodeproj/project.pbxproj'
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<component name="ProjectRunConfigurationManager">
2+
<configuration default="false" name="Example" type="FlutterRunConfigurationType" factoryName="Flutter">
3+
<option name="filePath" value="$PROJECT_DIR$/example/lib/main.dart" />
4+
<method v="2" />
5+
</configuration>
6+
</component>
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Miscellaneous
2+
*.class
3+
*.log
4+
*.pyc
5+
*.swp
6+
.DS_Store
7+
.atom/
8+
.build/
9+
.buildlog/
10+
.history
11+
.svn/
12+
.swiftpm/
13+
migrate_working_dir/
14+
15+
# IntelliJ related
16+
*.iml
17+
*.ipr
18+
*.iws
19+
.idea/
20+
21+
# The .vscode folder contains launch configuration and tasks you configure in
22+
# VS Code which you may wish to be included in version control, so this line
23+
# is commented out by default.
24+
#.vscode/
25+
26+
# Flutter/Dart/Pub related
27+
**/doc/api/
28+
**/ios/Flutter/.last_build_id
29+
.dart_tool/
30+
.flutter-plugins-dependencies
31+
.pub-cache/
32+
.pub/
33+
/build/
34+
/coverage/
35+
36+
# Symbolication related
37+
app.*.symbols
38+
39+
# Obfuscation related
40+
app.*.map.json
41+
42+
# Android Studio will place build artifacts here
43+
/android/app/debug
44+
/android/app/profile
45+
/android/app/release

0 commit comments

Comments
 (0)