diff --git a/swift/.changeset/lazy-data-transfer-files.md b/swift/.changeset/lazy-data-transfer-files.md new file mode 100644 index 00000000000..6ed10cdff15 --- /dev/null +++ b/swift/.changeset/lazy-data-transfer-files.md @@ -0,0 +1,9 @@ +--- +'lexical-swift': minor +--- + +**Breaking:** pasted and dropped files are no longer read into memory on the main thread. As a web `File` is a lazy `Blob`, a `DataTransferFile` now has a `size` and reads its contents only when a handler awaits `bytes()`, off the main thread, so a paste or drop of large files no longer blocks the view, even when no handler wants the files. `DataTransferFile.data` is gone, and the type is no longer `Equatable`. + +To migrate, read a file's contents with `try await file.bytes()` where you read `file.data`, as upstream's `mediaFileReader` awaits each `File`. A `DRAG_DROP_PASTE` handler that needs the bytes returns `true` and reads them in a `Task`. `DataTransferFile(name:type:data:)` still makes a file in memory, and `DataTransferFile(name:type:size:load:)` makes a lazy one. Compare files by `name` and `type` where you compared them with `==`. + +On macOS, a dropped or pasted file is read from its URL; an image on the pasteboard with no file behind it is read from the pasteboard and throws once the pasteboard has changed. On iOS, UIKit lets items load only during the paste or drop, so each file starts loading into a temporary file then, `bytes()` waits for it, and `size` is `nil`. iOS drops and pastes now also carry files that aren't images, such as a PDF from Files, which were lost before. diff --git a/swift/DESIGN.md b/swift/DESIGN.md index 84523d6a8cf..2f79d0d9d4c 100644 --- a/swift/DESIGN.md +++ b/swift/DESIGN.md @@ -717,7 +717,13 @@ Reversible choices made porting `@lexical/extension` under Q18. `application/x-lexical-editor` as `dev.lexical.editor`, the drag marker as `dev.lexical.drag`, `text/html` as `public.html`, `text/plain` and `text/uri-list` as their public types, and file URLs and images as - `files`. `copy:` and `cut:` dispatch `COPY_COMMAND` / `CUT_COMMAND` with a + `files`. Files load lazily, as a web `File` is a `Blob` read through a + promise: a `DataTransferFile` has its name, type and `size` up front and + reads its contents in `async bytes()`, off the main thread, so a paste or + drop of large files reads nothing until a handler asks. On iOS, UIKit + lets items load only during the paste or drop, so each file starts + loading into a temporary file then and `bytes()` waits for it; iOS drops + and pastes carry files of any type, not only images. `copy:` and `cut:` dispatch `COPY_COMMAND` / `CUT_COMMAND` with a `ClipboardEvent` and write its data when a handler prevented the default; `paste:` dispatches `PASTE_COMMAND`, and paste as plain text passes only `text/plain`. RTF without HTML (from native apps) is converted to HTML, diff --git a/swift/Sources/Lexical/Lexical.docc/JSParity.md b/swift/Sources/Lexical/Lexical.docc/JSParity.md index f8b652cc062..19eac9c5caf 100644 --- a/swift/Sources/Lexical/Lexical.docc/JSParity.md +++ b/swift/Sources/Lexical/Lexical.docc/JSParity.md @@ -84,4 +84,6 @@ Listed with their category in the repository's `swift/parity/deviations.json`: - **Clipboard.** RTF without HTML is converted to HTML before `PASTE_COMMAND`. Lexical's own clipboard data travels as the pasteboard type `dev.lexical.editor`. A drag moved into another editor is removed - from the source by the source view. + from the source by the source view. A pasted or dropped file is read when + a handler awaits `bytes()`; on iOS its load starts at the paste or drop, + since UIKit allows it only then, and its `size` is `nil`. diff --git a/swift/Sources/Lexical/LexicalEvents.swift b/swift/Sources/Lexical/LexicalEvents.swift index 6ea97252f91..6e4892f4a22 100644 --- a/swift/Sources/Lexical/LexicalEvents.swift +++ b/swift/Sources/Lexical/LexicalEvents.swift @@ -361,16 +361,41 @@ public func compileKeyboardShortcuts( // MARK: - Other DOM-shaped events -/// One file of a `DataTransfer`. -public struct DataTransferFile: Equatable, Sendable { +/// One file of a `DataTransfer`, as a web `File`: its name, type and size +/// are known up front, and its contents are read only when `bytes()` asks +/// for them, as `File.bytes()` reads a lazy `Blob`. +public struct DataTransferFile: Sendable { + /// `File.name`. public var name: String + /// `File.type`, a MIME type. public var type: String - public var data: [UInt8] + /// `File.size` in bytes, or `nil` when it isn't known without reading + /// the file (an item that is still loading, on iOS). + public var size: Int? + private let load: @Sendable () async throws -> [UInt8] + /// A file whose contents are `data`, in memory. public init(name: String, type: String, data: [UInt8] = []) { + self.init(name: name, type: type, size: data.count) { data } + } + + /// A file that `load` reads when `bytes()` is called, each time it is + /// called. + public init( + name: String, type: String, size: Int?, + load: @escaping @Sendable () async throws -> [UInt8] + ) { self.name = name self.type = type - self.data = data + self.size = size + self.load = load + } + + /// `File.bytes()`: the file's contents. Throws when the file can no + /// longer be read, such as one deleted since it was dropped, as a web + /// `File` rejects with `NotReadableError`. + public func bytes() async throws -> [UInt8] { + try await load() } } diff --git a/swift/Sources/LexicalAppKit/NSPasteboard+DataTransfer.swift b/swift/Sources/LexicalAppKit/NSPasteboard+DataTransfer.swift index 7293255ebd3..4e1b9c318e5 100644 --- a/swift/Sources/LexicalAppKit/NSPasteboard+DataTransfer.swift +++ b/swift/Sources/LexicalAppKit/NSPasteboard+DataTransfer.swift @@ -42,24 +42,41 @@ || (dataTransfer.types.isEmpty && availableType(from: [.png, .tiff]) != nil) return dataTransfer } + // The files are read only when a handler asks for their bytes, off + // the main thread, as a web `File` is a lazy blob. for url in urls { - guard let data = try? Data(contentsOf: url) else { continue } + let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]) + guard values?.isRegularFile == true else { continue } let type = UTType(filenameExtension: url.pathExtension)?.preferredMIMEType ?? TextKitPasteboard.mimeType(forPathExtension: url.pathExtension) dataTransfer.files.append( - DataTransferFile(name: url.lastPathComponent, type: type, data: Array(data))) + DataTransferFile(name: url.lastPathComponent, type: type, size: values?.fileSize) { + try await readFile(url) + }) } if dataTransfer.files.isEmpty, dataTransfer.types.isEmpty { // An image copied from an app, with no file behind it. - for (type, ext) in [(NSPasteboard.PasteboardType.png, "png"), (.tiff, "tiff")] { - if let data = data(forType: type) { - dataTransfer.files.append( - DataTransferFile( - name: "image.\(ext)", type: TextKitPasteboard.mimeType(forPathExtension: ext), - data: Array(data))) - break - } + for (type, ext) in [(NSPasteboard.PasteboardType.png, "png"), (.tiff, "tiff")] + where availableType(from: [type]) != nil { + nonisolated(unsafe) let pasteboard = self + let changeCount = changeCount + dataTransfer.files.append( + DataTransferFile( + name: "image.\(ext)", type: TextKitPasteboard.mimeType(forPathExtension: ext), + size: nil + ) { + // A pasteboard is read on the main thread, and only while it + // still holds what was pasted or dropped. + let data = try await MainActor.run { + guard pasteboard.changeCount == changeCount, + let data = pasteboard.data(forType: type) + else { throw CocoaError(.fileReadNoSuchFile) } + return data + } + return Array(data) + }) + break } } return dataTransfer @@ -86,6 +103,12 @@ } } + /// The contents of the file at `url`, read off the main thread. + @concurrent + private func readFile(_ url: URL) async throws -> [UInt8] { + Array(try Data(contentsOf: url)) + } + extension DataTransfer { /// A pasteboard item with this transfer's data, for the clipboard or a /// drag. diff --git a/swift/Sources/LexicalRichText/RichText.swift b/swift/Sources/LexicalRichText/RichText.swift index 53de9f80051..44c5d93dbc2 100644 --- a/swift/Sources/LexicalRichText/RichText.swift +++ b/swift/Sources/LexicalRichText/RichText.swift @@ -14,7 +14,23 @@ import LexicalUtils // paste, because a native control inside a decorator handles its own // clipboard; and the iOS text-file drop fall-through, a WebKit quirk. -/// `DRAG_DROP_PASTE`: files dropped or pasted into the editor. +/// `DRAG_DROP_PASTE`: files dropped or pasted into the editor. Nothing has +/// read them yet; a handler that wants their contents awaits each file's +/// `bytes()`, as upstream's `mediaFileReader` reads each `File`: +/// +/// ```swift +/// editor.registerCommand(DRAG_DROP_PASTE, { files, editor in +/// let images = files.filter { isMimeType($0, ["image/"]) } +/// if images.isEmpty { return false } +/// Task { @MainActor in +/// for image in images { +/// let bytes = try await image.bytes() +/// // insert the image +/// } +/// } +/// return true +/// }, COMMAND_PRIORITY_LOW) +/// ``` public let DRAG_DROP_PASTE = LexicalCommand<[DataTransferFile]>("DRAG_DROP_PASTE_FILE") /// `EscapeFormatTrigger`: the interactions that can escape a format. diff --git a/swift/Sources/LexicalUIKit/LexicalUITextView.swift b/swift/Sources/LexicalUIKit/LexicalUITextView.swift index f372f97ba06..456fb5a6a87 100644 --- a/swift/Sources/LexicalUIKit/LexicalUITextView.swift +++ b/swift/Sources/LexicalUIKit/LexicalUITextView.swift @@ -1099,8 +1099,9 @@ public func dropInteraction( _ interaction: UIDropInteraction, canHandle session: any UIDropSession ) -> Bool { + // Text, or any file (`DataTransfer.load`). session.hasItemsConforming( - toTypeIdentifiers: TextKitPasteboard.types.map(\.type) + ["public.image"]) + toTypeIdentifiers: TextKitPasteboard.types.map(\.type) + ["public.data"]) } public func dropInteraction( diff --git a/swift/Sources/LexicalUIKit/UIPasteboard+DataTransfer.swift b/swift/Sources/LexicalUIKit/UIPasteboard+DataTransfer.swift index 60a5a7bfba8..0f14151ecc0 100644 --- a/swift/Sources/LexicalUIKit/UIPasteboard+DataTransfer.swift +++ b/swift/Sources/LexicalUIKit/UIPasteboard+DataTransfer.swift @@ -33,36 +33,35 @@ } extension UIPasteboard { - /// The first item's contents as a `DataTransfer`. + /// The first item's contents as a `DataTransfer`, reading only the + /// types it carries. When it has no text, the items' images and other + /// files are its `files`, which start loading off the main thread now + /// (UIKit lets a paste's items load only while it happens) and which + /// `bytes()` waits for. public func lexicalDataTransfer(plainTextOnly: Bool = false) -> DataTransfer { let dataTransfer = DataTransfer() - guard let item = items.first else { return dataTransfer } + guard numberOfItems > 0 else { return dataTransfer } + let first = IndexSet(integer: 0) + func value(_ type: String) -> Any? { + values(forPasteboardType: type, inItemSet: first)?.first + } for (mime, type) in TextKitPasteboard.types { if plainTextOnly && mime != "text/plain" { continue } - if let value = pasteboardString(item[type]) { dataTransfer.setData(mime, value) } + if let value = pasteboardString(value(type)) { dataTransfer.setData(mime, value) } } if dataTransfer.getData("text/plain").isEmpty, - let value = pasteboardString(item["public.plain-text"]) + let value = pasteboardString(value("public.plain-text")) { dataTransfer.setData("text/plain", value) } if plainTextOnly { return dataTransfer } - if dataTransfer.getData("text/html").isEmpty, let rtf = item["public.rtf"] as? Data, + if dataTransfer.getData("text/html").isEmpty, let rtf = value("public.rtf") as? Data, let html = htmlFromRTF(rtf) { dataTransfer.setData("text/html", html) } if dataTransfer.types.isEmpty { - for (type, ext) in [("public.png", "png"), ("public.jpeg", "jpg")] { - if let data = (item[type] as? Data) ?? (item[type] as? UIImage).flatMap({ $0.pngData() }) - { - dataTransfer.files.append( - DataTransferFile( - name: "image.\(ext)", type: TextKitPasteboard.mimeType(forPathExtension: ext), - data: Array(data))) - break - } - } + dataTransfer.files = itemProviders.compactMap(DataTransferFile.loading) } return dataTransfer } @@ -99,7 +98,11 @@ return provider } - /// Loads the data of drag items, calling `completion` on the main queue. + /// Loads the text of drag items, calling `completion` on the main queue. + /// The items' images and other files, such as a PDF from Files, are its + /// `files`: they start loading off the main thread now, since UIKit lets + /// a drop's items load only while it is performed, and `completion` + /// doesn't wait for them; `bytes()` does. @MainActor public static func load( _ providers: [NSItemProvider], completion: @escaping @MainActor (DataTransfer) -> Void @@ -107,7 +110,6 @@ let dataTransfer = DataTransfer() let group = DispatchGroup() nonisolated(unsafe) var strings: [(String, String)] = [] - nonisolated(unsafe) var files: [DataTransferFile] = [] let lock = NSLock() for provider in providers { for (mime, type) in TextKitPasteboard.types @@ -120,23 +122,10 @@ group.leave() } } - if let type = provider.registeredTypeIdentifiers.first(where: { - UTType($0)?.conforms(to: .image) == true - }) { - group.enter() - provider.loadDataRepresentation(forTypeIdentifier: type) { data, _ in - if let data { - let utType = UTType(type) - let ext = utType?.preferredFilenameExtension ?? "png" - let file = DataTransferFile( - name: (provider.suggestedName ?? "image") + ".\(ext)", - type: utType?.preferredMIMEType ?? "image/png", data: Array(data)) - lock.withLock { files.append(file) } - } - group.leave() - } - } } + // Started here: UIKit lets a drop's items load only while it is + // performed. + let files = providers.compactMap(DataTransferFile.loading) group.notify(queue: .main) { MainActor.assumeIsolated { for (mime, value) in strings where dataTransfer.getData(mime).isEmpty { @@ -148,4 +137,81 @@ } } } + + extension DataTransferFile { + /// The file an item provider carries: an image, or an item with none + /// of `TextKitPasteboard`'s types, such as a PDF from Files; `nil` for + /// an item of text. UIKit lets a drop's or a paste's items load only + /// while it happens, so the file starts loading now, off the main + /// thread, into a temporary file that `bytes()` waits for and reads. + /// `size` is `nil`, since it is known only once the file has loaded. + static func loading(_ provider: NSItemProvider) -> DataTransferFile? { + let types = provider.registeredTypeIdentifiers + let isText = TextKitPasteboard.types.contains { types.contains($0.type) } + guard + let type = types.first(where: { UTType($0)?.conforms(to: .image) == true }) + ?? (isText ? nil : types.first { UTType($0)?.conforms(to: .data) == true }) + else { return nil } + let utType = UTType(type) + var name = + provider.suggestedName ?? (utType?.conforms(to: .image) == true ? "image" : "file") + if let ext = utType?.preferredFilenameExtension, + UTType(filenameExtension: (name as NSString).pathExtension) != utType + { + name += ".\(ext)" + } + let file = ItemProviderFile(provider, type) + return DataTransferFile( + name: name, + type: utType?.preferredMIMEType + ?? TextKitPasteboard.mimeType(forPathExtension: (name as NSString).pathExtension), + size: nil + ) { try await file.bytes() } + } + } + + /// An item provider's file, moved into a temporary directory as it + /// loads, and removed once no `DataTransferFile` holds it. + private final class ItemProviderFile: Sendable { + private let location: Task + + init(_ provider: NSItemProvider, _ type: String) { + let (loaded, continuation) = AsyncThrowingStream.makeStream() + // The provider removes its file when this returns, so it is moved. + _ = provider.loadFileRepresentation(forTypeIdentifier: type) { url, error in + do { + guard let url else { throw error ?? CocoaError(.fileReadUnknown) } + let directory = FileManager.default.temporaryDirectory + .appending(path: "lexical-\(UUID().uuidString)", directoryHint: .isDirectory) + try FileManager.default.createDirectory( + at: directory, withIntermediateDirectories: true) + let destination = directory.appending(path: url.lastPathComponent) + try FileManager.default.moveItem(at: url, to: destination) + continuation.yield(destination) + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + location = Task.detached { + for try await url in loaded { return url } + throw CocoaError(.fileReadUnknown) + } + } + + deinit { + let location = location + Task.detached { + if let url = try? await location.value { + try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) + } + } + } + + /// The file's contents, once it has loaded, read off the main thread. + @concurrent + func bytes() async throws -> [UInt8] { + Array(try Data(contentsOf: try await location.value)) + } + } #endif diff --git a/swift/Tests/LexicalAppKitTests/DecoratorAndDemoTests.swift b/swift/Tests/LexicalAppKitTests/DecoratorAndDemoTests.swift index 5e9397fca10..ec631ff3ae3 100644 --- a/swift/Tests/LexicalAppKitTests/DecoratorAndDemoTests.swift +++ b/swift/Tests/LexicalAppKitTests/DecoratorAndDemoTests.swift @@ -279,10 +279,11 @@ #expect(view.draggingUpdated(other) != .move) } - /// A file dragged over the view is not read until it is dropped: as a - /// browser's `dragover` does, `DRAGOVER_COMMAND`'s transfer lists the - /// `Files` type with no files, and the drop's has the file's data. - @Test func readsADraggedFileOnlyWhenItIsDropped() throws { + /// A dragged file is read only when a handler asks for its bytes: as + /// a browser's `dragover` does, `DRAGOVER_COMMAND`'s transfer lists the + /// `Files` type with no files, and the drop's file has the file's name + /// and size and reads it in `bytes()`, as a web `File` reads its blob. + @Test func readsADroppedFileOnlyWhenItsBytesAreRead() async throws { let (view, window) = try showDemo() var overTypes: [String] = [] var overFiles: [DataTransferFile] = [] @@ -314,10 +315,35 @@ let drag = Drag(from: nil, over: window, at: point, pasteboard) _ = view.draggingUpdated(drag) #expect(overTypes.contains("Files")) - #expect(overFiles.map(\.data.count) == []) + #expect(overFiles.isEmpty) _ = view.performDragOperation(drag) #expect(droppedFiles.map(\.name) == [url.lastPathComponent]) - #expect(droppedFiles.map(\.data) == [Array("dragged".utf8)]) + #expect(droppedFiles.map(\.type) == ["text/plain"]) + #expect(droppedFiles.map(\.size) == [7]) + let file = try #require(droppedFiles.first) + // The drop read nothing: the bytes are the file's when they're read. + try Data("changed since".utf8).write(to: url) + #expect(try await file.bytes() == Array("changed since".utf8)) + try FileManager.default.removeItem(at: url) + await #expect(throws: (any Error).self) { try await file.bytes() } + } + + /// An image on the pasteboard with no file behind it, as an app copies + /// one, is read when its bytes are, and only while the pasteboard still + /// holds it. + @Test func readsAPastedImageOnlyWhileThePasteboardHoldsIt() async throws { + let pasteboard = NSPasteboard(name: .init("lexical-test-\(UUID().uuidString)")) + defer { pasteboard.releaseGlobally() } + pasteboard.clearContents() + let png: [UInt8] = [0x89, 0x50, 0x4E, 0x47, 1, 2, 3] + pasteboard.setData(Data(png), forType: .png) + let file = try #require(pasteboard.lexicalDataTransfer().files.first) + #expect(file.name == "image.png") + #expect(file.type == "image/png") + #expect(try await file.bytes() == png) + pasteboard.clearContents() + pasteboard.setData(Data([9]), forType: .png) + await #expect(throws: (any Error).self) { try await file.bytes() } } /// A file dragged over the view is taken as a copy, as a contenteditable diff --git a/swift/Tests/LexicalRichTextTests/RichTextClipboardFilesTests.swift b/swift/Tests/LexicalRichTextTests/RichTextClipboardFilesTests.swift index e251ea4beb9..8005b3bd198 100644 --- a/swift/Tests/LexicalRichTextTests/RichTextClipboardFilesTests.swift +++ b/swift/Tests/LexicalRichTextTests/RichTextClipboardFilesTests.swift @@ -91,8 +91,8 @@ private func richTextEditor( let editor = try richTextEditor(filesAlwaysWin) defer { editor.dispose() } #expect( - try paste(editor, dataTransfer([("text/html", #""#)], [fakeImage])) - == [fakeImage]) + try paste(editor, dataTransfer([("text/html", #""#)], [fakeImage]))? + .map(\.name) == [fakeImage.name]) } @Test( @@ -130,7 +130,7 @@ private func richTextEditor( func fileOnly() throws { let editor = try richTextEditor() defer { editor.dispose() } - #expect(try paste(editor, dataTransfer([], [fakeImage])) == [fakeImage]) + #expect(try paste(editor, dataTransfer([], [fakeImage]))?.map(\.name) == [fakeImage.name]) } @Test( @@ -184,7 +184,7 @@ private final class CaretRoot: LexicalRootElement { let files = try captureFiles(editor) { _ = try editor.dispatchCommand(DROP_COMMAND, dropEvent([fakeImage])) } - #expect(files == [fakeImage]) + #expect(files?.map(\.name) == [fakeImage.name]) } @Test( @@ -207,7 +207,7 @@ private final class CaretRoot: LexicalRootElement { let files = try captureFiles(editor) { _ = try editor.dispatchCommand(DROP_COMMAND, dropEvent([fakeImage])) } - #expect(files == [fakeImage]) + #expect(files?.map(\.name) == [fakeImage.name]) try editor.setRootElement(nil) } } diff --git a/swift/Tests/LexicalUIKitTests/LexicalUITextViewTests.swift b/swift/Tests/LexicalUIKitTests/LexicalUITextViewTests.swift index a95d3df5341..5b4205d2ebf 100644 --- a/swift/Tests/LexicalUIKitTests/LexicalUITextViewTests.swift +++ b/swift/Tests/LexicalUIKitTests/LexicalUITextViewTests.swift @@ -364,6 +364,39 @@ _ = window } + /// A pasted image is a file of the paste, read when its bytes are. + @Test func pastesAnImageAsAFile() async throws { + let (view, window) = try makeView() + let png: [UInt8] = [0x89, 0x50, 0x4E, 0x47, 1, 2, 3] + view.pasteboard.setItems([["public.png": Data(png)]]) + let files = view.pasteboard.lexicalDataTransfer().files + #expect(files.map(\.name) == ["image.png"]) + #expect(files.map(\.type) == ["image/png"]) + #expect(try await files.first?.bytes() == png) + _ = window + } + + /// A dropped file that isn't an image is a file of the drop too, as a + /// web drop's `files` holds every file; a drop of text has none. + @Test func dropsAFileThatIsNotAnImage() async throws { + let pdf = Array("%PDF-1.7 report".utf8) + let report = NSItemProvider(item: Data(pdf) as NSData, typeIdentifier: "com.adobe.pdf") + report.suggestedName = "Report" + let text = NSItemProvider(object: "dropped" as NSString) + nonisolated(unsafe) var dataTransfer: DataTransfer? + await withCheckedContinuation { continuation in + DataTransfer.load([report, text]) { + dataTransfer = $0 + continuation.resume() + } + } + let files = try #require(dataTransfer).files + #expect(files.map(\.name) == ["Report.pdf"]) + #expect(files.map(\.type) == ["application/pdf"]) + #expect(dataTransfer?.getData("text/plain") == "dropped") + #expect(try await files.first?.bytes() == pdf) + } + @Test func centersTextInCSSLineBoxes() throws { let (view, window) = try makeView() // Lines of capitals, whose ink runs from the cap height to the diff --git a/swift/apps/lexical-playground-swift/Sources/ImagesExtension.swift b/swift/apps/lexical-playground-swift/Sources/ImagesExtension.swift index a46425191cf..751c3aed061 100644 --- a/swift/apps/lexical-playground-swift/Sources/ImagesExtension.swift +++ b/swift/apps/lexical-playground-swift/Sources/ImagesExtension.swift @@ -221,14 +221,15 @@ let DragDropPasteExtension = defineExtension( { files, _ in let images = files.filter { isMimeType($0, ACCEPTABLE_IMAGE_TYPES) } if images.isEmpty { return false } - // JS reads the files asynchronously, then inserts each. - let payloads = images.map { - InsertImagePayload( - altText: $0.name, - src: "data:\($0.type);base64,\(Data($0.data).base64EncodedString())") - } + // As `mediaFileReader` does, the files are read in order, then + // inserted together. nonisolated(unsafe) let editor = editor - DispatchQueue.main.async { + Task { @MainActor in + var payloads: [InsertImagePayload] = [] + for image in images { + guard let src = try? await dataURL(image) else { continue } + payloads.append(InsertImagePayload(altText: image.name, src: src)) + } for payload in payloads { _ = try? editor.dispatchCommand(INSERT_IMAGE_COMMAND, payload) } @@ -236,3 +237,9 @@ let DragDropPasteExtension = defineExtension( return true }, COMMAND_PRIORITY_LOW) }) + +/// `FileReader.readAsDataURL(file)`, off the main thread. +@concurrent +private func dataURL(_ file: DataTransferFile) async throws -> String { + "data:\(file.type);base64,\(Data(try await file.bytes()).base64EncodedString())" +} diff --git a/swift/parity/deviations.json b/swift/parity/deviations.json index 4b3a6f2f3aa..caf9fc86e54 100644 --- a/swift/parity/deviations.json +++ b/swift/parity/deviations.json @@ -35,6 +35,10 @@ "category": "platform-clipboard", "description": "application/x-lexical-editor travels on the pasteboard as the type dev.lexical.editor. Safari keeps a page's custom MIME types in WebKit's private pasteboard data, so Lexical JSON copied in a browser reaches the native views as HTML and text, and the reverse." }, + "native/pasteboard-files": { + "category": "platform-clipboard", + "description": "A pasted or dropped file is read only when a handler awaits bytes(), as a web File is a lazy Blob, but its source can differ from a browser's snapshot. On macOS, an image on the pasteboard with no file behind it is read from the pasteboard and throws once the pasteboard has changed. On iOS, UIKit lets a paste's or a drop's items load only while it happens, so each file starts loading into a temporary file at once and bytes() waits for it, and its size is nil. iOS drops and pastes carry files of any type, as the web's do; a pasted item counts as a file only when the pasteboard has no text." + }, "native/drag-move-between-editors": { "category": "platform-clipboard", "description": "A drag moved into another editor is removed from the source by the source view when its drag session ends as a move (deleteByDrag there), where JS has the drop side find the source editor's root in the shared DOM."