|
| 1 | +import 'dart:typed_data' show Uint8List; |
| 2 | + |
1 | 3 | import 'package:attributed_text/attributed_text.dart'; |
2 | 4 | import 'package:flutter/material.dart'; |
3 | 5 | import 'package:super_editor/src/default_editor/layout_single_column/selection_aware_viewmodel.dart'; |
@@ -80,7 +82,6 @@ class ImageNode extends BlockNode { |
80 | 82 | ); |
81 | 83 | } |
82 | 84 |
|
83 | | - @override |
84 | 85 | ImageNode copy() { |
85 | 86 | return ImageNode( |
86 | 87 | id: id, |
@@ -298,3 +299,288 @@ class ExpectedSize { |
298 | 299 | @override |
299 | 300 | int get hashCode => width.hashCode ^ height.hashCode; |
300 | 301 | } |
| 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 | +} |
0 commit comments