diff --git a/Lexical/Core/Editor.swift b/Lexical/Core/Editor.swift index d6a11de3..4e2e9e3c 100644 --- a/Lexical/Core/Editor.swift +++ b/Lexical/Core/Editor.swift @@ -221,7 +221,9 @@ public class Editor: NSObject { // MARK: - Registration - public func registerErrorListener(listener: @escaping ErrorListener) -> () -> Void { + public typealias RemovalHandler = () -> Void + + public func registerErrorListener(listener: @escaping ErrorListener) -> RemovalHandler { let uuid = UUID() self.listeners.errors[uuid] = listener @@ -235,7 +237,7 @@ public class Editor: NSObject { /// Registers a closure to be run whenever the ``EditorState`` changes. /// - Parameter listener: The code to run when the ``EditorState`` changes. /// - Returns: A closure to remove the update listener - public func registerUpdateListener(listener: @escaping UpdateListener) -> () -> Void { + public func registerUpdateListener(listener: @escaping UpdateListener) -> RemovalHandler { let uuid = UUID() self.listeners.update[uuid] = listener return { [weak self] in @@ -247,7 +249,7 @@ public class Editor: NSObject { /// Registers a closure to be run whenever the reconciled text content changes. /// - Parameter listener: The code to run when the text content changes /// - Returns: A closure to remove the text content listener - public func registerTextContentListener(listener: @escaping TextContentListener) -> () -> Void { + public func registerTextContentListener(listener: @escaping TextContentListener) -> RemovalHandler { let uuid = UUID() self.listeners.textContent[uuid] = listener @@ -264,7 +266,7 @@ public class Editor: NSObject { /// - listener: The code to run when the command is dispatched. /// - priority: The priority for your handler. Higher priority handlers run before lower priority handlers. /// - Returns: A closure to remove the command handler. - public func registerCommand(type: CommandType, listener: @escaping CommandListener, priority: CommandPriority = CommandPriority.Editor) -> () -> Void { + public func registerCommand(type: CommandType, listener: @escaping CommandListener, priority: CommandPriority = CommandPriority.Editor) -> RemovalHandler { let uuid = UUID() if self.commands[type] == nil { diff --git a/Lexical/Core/Events.swift b/Lexical/Core/Events.swift index 7142485c..b3d016d6 100644 --- a/Lexical/Core/Events.swift +++ b/Lexical/Core/Events.swift @@ -13,20 +13,19 @@ import UIKit internal func onInsertTextFromUITextView(text: String, editor: Editor, updateMode: UpdateBehaviourModificationMode = UpdateBehaviourModificationMode()) throws { try editor.updateWithCustomBehaviour(mode: updateMode) { - guard let selection = try getSelection() as? RangeSelection else { - // should have a range selection if UITextView is first responder + guard let selection = try getSelection() else { editor.log(.UITextView, .error, "Expected a selection here") return } - if let markedTextOperation = updateMode.markedTextOperation, markedTextOperation.createMarkedText == true { + if let markedTextOperation = updateMode.markedTextOperation, markedTextOperation.createMarkedText == true, let rangeSelection = selection as? RangeSelection { // Here we special case STARTING or UPDATING a marked text operation. - try selection.applySelectionRange(markedTextOperation.selectionRangeToReplace, affinity: .forward) - } else if let markedRange = editor.getNativeSelection().markedRange { + try rangeSelection.applySelectionRange(markedTextOperation.selectionRangeToReplace, affinity: .forward) + } else if let markedRange = editor.getNativeSelection().markedRange, let rangeSelection = selection as? RangeSelection { // Here we special case ENDING a marked text operation by replacing all the marked text with the incoming text. // This is usually used by hardware keyboards e.g. when typing e-acute. Software keyboards such as Japanese // do not seem to use this way of ending marked text. - try selection.applySelectionRange(markedRange, affinity: .forward) + try rangeSelection.applySelectionRange(markedRange, affinity: .forward) } if text == "\n" || text == "\u{2029}" { @@ -63,7 +62,7 @@ internal func onRemoveTextFromUITextView(editor: Editor) throws { } internal func onDeleteBackwardsFromUITextView(editor: Editor) throws { - guard let editor = getActiveEditor(), let selection = try getSelection() as? RangeSelection else { + guard let editor = getActiveEditor(), let selection = try getSelection() else { throw LexicalError.invariantViolation("No editor or selection") } diff --git a/Lexical/Core/Nodes/DecoratorNode.swift b/Lexical/Core/Nodes/DecoratorNode.swift index f77d8789..190e63ae 100644 --- a/Lexical/Core/Nodes/DecoratorNode.swift +++ b/Lexical/Core/Nodes/DecoratorNode.swift @@ -40,7 +40,7 @@ import UIKit - ``createView()`` - ``decorate(view:)`` - - ``sizeForDecoratorView(textViewWidth:)`` + - ``sizeForDecoratorView(textViewWidth:attributes:)`` ### Optional methods to override diff --git a/Lexical/Core/Reconciler.swift b/Lexical/Core/Reconciler.swift index eda521f6..a85ad2dd 100644 --- a/Lexical/Core/Reconciler.swift +++ b/Lexical/Core/Reconciler.swift @@ -253,7 +253,13 @@ internal enum Reconciler { return } - if shouldReconcileSelection && (needsUpdate || nextSelection == nil) { + var selectionsAreDifferent = false + if let nextSelection, let currentSelection { + let isSame = nextSelection.isSelection(currentSelection) + selectionsAreDifferent = !isSame + } + + if shouldReconcileSelection && (needsUpdate || nextSelection == nil || selectionsAreDifferent) { try reconcileSelection(prevSelection: currentSelection, nextSelection: nextSelection, editor: editor) } } diff --git a/Lexical/Core/Selection/BaseSelection.swift b/Lexical/Core/Selection/BaseSelection.swift index 817bbcd2..0da13860 100644 --- a/Lexical/Core/Selection/BaseSelection.swift +++ b/Lexical/Core/Selection/BaseSelection.swift @@ -7,13 +7,59 @@ import Foundation +/** + This protocol represents things common to all types of selection. + */ public protocol BaseSelection: AnyObject, CustomDebugStringConvertible { + /// True if the selection has had any changes made that need reconciling. var dirty: Bool { get set } + + /// Makes an identical copy of this selection. func clone() -> BaseSelection + + /// Extracts the nodes in the Selection, splitting nodes if necessary to get offset-level precision. func extract() throws -> [Node] + + /// Returns all the nodes in or partially in the Selection. This function is designed to be more performant than ``extract()``. func getNodes() throws -> [Node] + + /// Returns a plain text representation of the content of the selection. func getTextContent() throws -> String + + /// Attempts to insert the provided text into the EditorState at the current Selection, converting tabs, newlines, and carriage returns into LexicalNodes. func insertRawText(_ text: String) throws + + /// Checks for selection equality. func isSelection(_ selection: BaseSelection) -> Bool + + // MARK: - Handling incoming events + +/** + * Attempts to "intelligently" insert an arbitrary list of Lexical nodes into the EditorState at the + * current Selection according to a set of heuristics that determine how surrounding nodes + * should be changed, replaced, or moved to accomodate the incoming ones. + * + * - Parameter nodes: the nodes to insert + * - Parameter selectStart: whether or not to select the start after the insertion. + * - Returns: true if the nodes were inserted successfully, false otherwise. + */ func insertNodes(nodes: [Node], selectStart: Bool) throws -> Bool + + /// Does the equivalent of pressing the backspace key. + func deleteCharacter(isBackwards: Bool) throws + + /// Handles a delete word event, e.g. option-backspace on Apple platforms + func deleteWord(isBackwards: Bool) throws + + /// Handles a delete line event, e.g. command-backspace on Apple platforms + func deleteLine(isBackwards: Bool) throws + + /// Handles the user pressing carriage-return + func insertParagraph() throws + + /// Handles inserting a soft line break (which does not split paragraphs) + func insertLineBreak(selectStart: Bool) throws + + /// Handles user-provided text to insert, applying a series of insertion heuristics based on the selection type and position. + func insertText(_ text: String) throws } diff --git a/Lexical/Core/Selection/GridSelection.swift b/Lexical/Core/Selection/GridSelection.swift index 5b40d4ca..3215c4b8 100644 --- a/Lexical/Core/Selection/GridSelection.swift +++ b/Lexical/Core/Selection/GridSelection.swift @@ -9,6 +9,7 @@ import Foundation import UIKit public class GridSelection: BaseSelection { + public func getTextContent() throws -> String { return "" } @@ -50,6 +51,30 @@ public class GridSelection: BaseSelection { // TODO return false } + + public func deleteCharacter(isBackwards: Bool) throws { + // TODO + } + + public func deleteWord(isBackwards: Bool) throws { + // TODO + } + + public func deleteLine(isBackwards: Bool) throws { + // TODO + } + + public func insertParagraph() throws { + // TODO + } + + public func insertLineBreak(selectStart: Bool) throws { + // TODO + } + + public func insertText(_ text: String) throws { + // TODO + } } extension GridSelection: CustomDebugStringConvertible { diff --git a/Lexical/Core/Selection/NodeSelection.swift b/Lexical/Core/Selection/NodeSelection.swift index 6392def7..88ccdd60 100644 --- a/Lexical/Core/Selection/NodeSelection.swift +++ b/Lexical/Core/Selection/NodeSelection.swift @@ -10,6 +10,7 @@ import UIKit public class NodeSelection: BaseSelection { + public var nodes: Set public var dirty: Bool = false @@ -26,6 +27,7 @@ public class NodeSelection: BaseSelection { nodes.insert(key) } + /// This confusingly named function removes nodes from the selection. It doesn't delete the nodes from the document! public func delete(key: NodeKey) { dirty = true nodes.remove(key) @@ -79,6 +81,61 @@ public class NodeSelection: BaseSelection { // TODO return false } + + public func deleteCharacter(isBackwards: Bool) throws { + for node in try getNodes() { + try node.remove() + } + } + + public func deleteWord(isBackwards: Bool) throws { + try deleteCharacter(isBackwards: isBackwards) + } + + public func deleteLine(isBackwards: Bool) throws { + try deleteCharacter(isBackwards: isBackwards) + } + + public func insertParagraph() throws { + guard isSingleNode(), let node = try getNodes().first else { + return + } + let rangeSelection = try rangeSelectionForNode(node) + try rangeSelection.insertParagraph() + } + + public func insertLineBreak(selectStart: Bool) throws { + guard isSingleNode(), let node = try getNodes().first else { + return + } + let rangeSelection = try rangeSelectionForNode(node) + try rangeSelection.insertLineBreak(selectStart: selectStart) + } + + public func insertText(_ text: String) throws { + guard isSingleNode(), let node = try getNodes().first else { + return + } + let rangeSelection = try rangeSelectionForNode(node) + try rangeSelection.insertText(text) + } + + // MARK: - Private + + private func isSingleNode() -> Bool { + return nodes.count == 1 + } + + // This function is specifically for getting a range selection for a single node in order to apply some incoming event to it, + // e.g. some replacement text. + private func rangeSelectionForNode(_ node: Node) throws -> RangeSelection { + guard let parent = node.getParent(), let nodeIndexInParent = node.getIndexWithinParent() else { + throw LexicalError.invariantViolation("cannot apply to root or unattached node") + } + let anchor = Point(key: parent.getKey(), offset: nodeIndexInParent, type: .element) + let focus = Point(key: parent.getKey(), offset: nodeIndexInParent + 1, type: .element) + return RangeSelection(anchor: anchor, focus: focus, format: TextFormat()) + } } extension NodeSelection: CustomDebugStringConvertible { diff --git a/Lexical/Core/Selection/RangeSelection.swift b/Lexical/Core/Selection/RangeSelection.swift index 496c85a2..68ce7d2f 100644 --- a/Lexical/Core/Selection/RangeSelection.swift +++ b/Lexical/Core/Selection/RangeSelection.swift @@ -63,16 +63,16 @@ public class RangeSelection: BaseSelection { if let elementNode = firstNode as? ElementNode, let descendent = elementNode.getDescendantByIndex(index: startOffset) { firstNode = descendent } - if let elementNode = lastNode as? ElementNode { - var lastNodeDescendant = elementNode.getDescendantByIndex(index: endOffset) + if let lastNodeUnwrapped = lastNode as? ElementNode { + var lastNodeDescendant = lastNodeUnwrapped.getDescendantByIndex(index: endOffset) // We don't want to over-select, as node selection infers the child before // the last descendant, not including that descendant. if let lastNodeDescendantUnwrapped = lastNodeDescendant, lastNodeDescendantUnwrapped != firstNode, - elementNode.getChildAtIndex(index: endOffset) == lastNodeDescendant { + lastNodeUnwrapped.getChildAtIndex(index: endOffset) == lastNodeDescendantUnwrapped { lastNodeDescendant = lastNodeDescendantUnwrapped.getPreviousSibling() } - lastNode = lastNodeDescendant ?? lastNode + lastNode = lastNodeDescendant ?? lastNodeUnwrapped } if firstNode == lastNode { if let firstNode = firstNode as? ElementNode, firstNode.getChildrenSize() > 0 { @@ -705,7 +705,7 @@ public class RangeSelection: BaseSelection { // MARK: - Internal - internal func insertParagraph() throws { + public func insertParagraph() throws { if !isCollapsed() { try removeText() } @@ -841,7 +841,7 @@ public class RangeSelection: BaseSelection { } } - internal func deleteCharacter(isBackwards: Bool) throws { + public func deleteCharacter(isBackwards: Bool) throws { if isCollapsed() { let node = try anchor.getNode() diff --git a/Lexical/Core/Selection/SelectionUtils.swift b/Lexical/Core/Selection/SelectionUtils.swift index bba56705..07cc7710 100644 --- a/Lexical/Core/Selection/SelectionUtils.swift +++ b/Lexical/Core/Selection/SelectionUtils.swift @@ -455,6 +455,11 @@ func transferStartingElementPointToTextPoint(start: Point, end: Point, format: T try element.append([target]) } else { placementNode = try placementNode?.insertBefore(nodeToInsert: target) + // fix the end point offset if it refers to the same element as start, + // as we've now inserted another element before it. + if end.type == .element && end.key == start.key { + end.updatePoint(key: end.key, offset: end.offset + 1, type: .element) + } } if start == end { diff --git a/Lexical/Documentation.docc/BaseSelection.md b/Lexical/Documentation.docc/BaseSelection.md new file mode 100644 index 00000000..69f4a37c --- /dev/null +++ b/Lexical/Documentation.docc/BaseSelection.md @@ -0,0 +1,30 @@ +# ``Lexical/BaseSelection`` + +## Topics + +### Cloning & Equality + +- ``BaseSelection/clone()`` +- ``isSelection(_:)`` +- ``dirty`` + +### Reading + +- ``getNodes()`` +- ``extract()`` +- ``getTextContent()`` + +### Modifying + +- ``insertNodes(nodes:selectStart:)`` +- ``insertRawText(_:)`` + +### Event handling + +- ``deleteCharacter(isBackwards:)`` +- ``deleteWord(isBackwards:)`` +- ``deleteLine(isBackwards:)`` +- ``insertText(_:)`` +- ``insertParagraph()`` +- ``insertLineBreak(selectStart:)`` + diff --git a/Lexical/Documentation.docc/Lexical.md b/Lexical/Documentation.docc/Lexical.md index 1f64d57f..c3edff56 100644 --- a/Lexical/Documentation.docc/Lexical.md +++ b/Lexical/Documentation.docc/Lexical.md @@ -25,6 +25,7 @@ Lexical for iOS is an extensible text rendering and editing framework written in - ``Editor`` - ``EditorState`` - ``EditorConfig`` +- ``BaseSelection`` - ``RangeSelection`` - ``Theme`` - ``Plugin`` @@ -68,6 +69,7 @@ Lexical for iOS is an extensible text rendering and editing framework written in - ``BaseSelection`` - ``SelectionType`` +- ``RangeSelection`` - ``NodeSelection`` - ``GridSelection`` - ``Point`` diff --git a/Lexical/LexicalView/LexicalView.swift b/Lexical/LexicalView/LexicalView.swift index 70a1f670..b315e5a3 100644 --- a/Lexical/LexicalView/LexicalView.swift +++ b/Lexical/LexicalView/LexicalView.swift @@ -64,7 +64,7 @@ public class LexicalView: UIView, Frontend { guard let textStorage = textView.textStorage as? TextStorage else { fatalError() } - self.responderForNodeSelection = ResponderForNodeSelection(editor: textView.editor, textStorage: textStorage) + self.responderForNodeSelection = ResponderForNodeSelection(editor: textView.editor, textStorage: textStorage, nextResponder: textView) super.init(frame: .zero) diff --git a/Lexical/LexicalView/ResponderForNodeSelection.swift b/Lexical/LexicalView/ResponderForNodeSelection.swift index 66800423..ac0c44e9 100644 --- a/Lexical/LexicalView/ResponderForNodeSelection.swift +++ b/Lexical/LexicalView/ResponderForNodeSelection.swift @@ -12,10 +12,12 @@ class ResponderForNodeSelection: UIResponder, UIKeyInput { private weak var editor: Editor? private weak var textStorage: TextStorage? + private weak var textView: UIResponder? - init(editor: Editor, textStorage: TextStorage) { + init(editor: Editor, textStorage: TextStorage, nextResponder: UIResponder) { self.editor = editor self.textStorage = textStorage + self.textView = nextResponder } var hasText: Bool { @@ -34,4 +36,14 @@ class ResponderForNodeSelection: UIResponder, UIKeyInput { func deleteBackward() { editor?.dispatchCommand(type: .deleteCharacter, payload: true) } + + override var canBecomeFirstResponder: Bool { + return true + } + + override var next: UIResponder? { + get { + textView + } + } } diff --git a/Lexical/TextView/TextView.swift b/Lexical/TextView/TextView.swift index b78670bd..cd601b0b 100644 --- a/Lexical/TextView/TextView.swift +++ b/Lexical/TextView/TextView.swift @@ -371,6 +371,14 @@ class TextView: UITextView { private func hidePlaceholderLabel() { placeholderLabel.isHidden = true } + + override func becomeFirstResponder() -> Bool { + let r = super.becomeFirstResponder() + if r == true { + onSelectionChange(editor: editor) + } + return r + } } extension TextView: UITextViewDelegate { diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 00000000..f71cd7f3 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "swiftsoup", + "kind" : "remoteSourceControl", + "location" : "https://github.com/scinfu/SwiftSoup.git", + "state" : { + "revision" : "0e96a20ffd37a515c5c963952d4335c89bed50a6", + "version" : "2.6.0" + } + } + ], + "version" : 2 +} diff --git a/Package.swift b/Package.swift index 68c74a09..c07f5521 100644 --- a/Package.swift +++ b/Package.swift @@ -30,6 +30,9 @@ let package = Package( .library( name: "LexicalInlineImagePlugin", targets: ["LexicalInlineImagePlugin"]), + .library( + name: "SelectableDecoratorNode", + targets: ["SelectableDecoratorNode"]), ], dependencies: [ .package(url: "https://github.com/scinfu/SwiftSoup.git", from: "2.6.0"), @@ -73,11 +76,16 @@ let package = Package( .target( name: "LexicalInlineImagePlugin", - dependencies: ["Lexical"], + dependencies: ["Lexical", "SelectableDecoratorNode"], path: "./Plugins/LexicalInlineImagePlugin/LexicalInlineImagePlugin"), .testTarget( name: "LexicalInlineImagePluginTests", dependencies: ["Lexical", "LexicalInlineImagePlugin"], path: "./Plugins/LexicalInlineImagePlugin/LexicalInlineImagePluginTests"), + + .target( + name: "SelectableDecoratorNode", + dependencies: ["Lexical"], + path: "./Plugins/SelectableDecoratorNode/SelectableDecoratorNode"), ] ) diff --git a/Playground/LexicalPlayground.xcodeproj/project.pbxproj b/Playground/LexicalPlayground.xcodeproj/project.pbxproj index 8dd1a185..02f677e2 100644 --- a/Playground/LexicalPlayground.xcodeproj/project.pbxproj +++ b/Playground/LexicalPlayground.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ 0656CF9029D1ED8F009CA08F /* ToolbarPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0656CF8F29D1ED8F009CA08F /* ToolbarPlugin.swift */; }; 0656CF9929D23743009CA08F /* Lexical in Frameworks */ = {isa = PBXBuildFile; productRef = 0656CF9829D23743009CA08F /* Lexical */; }; 0656CFA229D48391009CA08F /* LexicalListPlugin in Frameworks */ = {isa = PBXBuildFile; productRef = 0656CFA129D48391009CA08F /* LexicalListPlugin */; }; + 065B5C862A22291C003A38DB /* SelectableDecoratorNode in Frameworks */ = {isa = PBXBuildFile; productRef = 065B5C852A22291C003A38DB /* SelectableDecoratorNode */; }; 069E59DB2A1EBF1700CA4296 /* LexicalHTML in Frameworks */ = {isa = PBXBuildFile; productRef = 069E59DA2A1EBF1700CA4296 /* LexicalHTML */; }; 069E59DD2A1F726D00CA4296 /* ExportOutputViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 069E59DC2A1F726D00CA4296 /* ExportOutputViewController.swift */; }; 069E59DF2A1F7EFB00CA4296 /* LexicalListHTMLSupport in Frameworks */ = {isa = PBXBuildFile; productRef = 069E59DE2A1F7EFB00CA4296 /* LexicalListHTMLSupport */; }; @@ -43,6 +44,7 @@ buildActionMask = 2147483647; files = ( 0656CFA229D48391009CA08F /* LexicalListPlugin in Frameworks */, + 065B5C862A22291C003A38DB /* SelectableDecoratorNode in Frameworks */, 069E59DB2A1EBF1700CA4296 /* LexicalHTML in Frameworks */, 069E59DF2A1F7EFB00CA4296 /* LexicalListHTMLSupport in Frameworks */, 0630DC162A2F624D009EB23B /* LexicalLinkPlugin in Frameworks */, @@ -127,6 +129,7 @@ 069E59DE2A1F7EFB00CA4296 /* LexicalListHTMLSupport */, 0630DC152A2F624D009EB23B /* LexicalLinkPlugin */, 06F7CF932A542E4E0024CD5A /* LexicalInlineImagePlugin */, + 065B5C852A22291C003A38DB /* SelectableDecoratorNode */, ); productName = LexicalPlayground; productReference = 0656CF7529D1E438009CA08F /* LexicalPlayground.app */; @@ -420,6 +423,10 @@ isa = XCSwiftPackageProductDependency; productName = LexicalListPlugin; }; + 065B5C852A22291C003A38DB /* SelectableDecoratorNode */ = { + isa = XCSwiftPackageProductDependency; + productName = SelectableDecoratorNode; + }; 069E59DA2A1EBF1700CA4296 /* LexicalHTML */ = { isa = XCSwiftPackageProductDependency; productName = LexicalHTML; diff --git a/Playground/LexicalPlayground/ToolbarPlugin.swift b/Playground/LexicalPlayground/ToolbarPlugin.swift index 353924cf..714a10a6 100644 --- a/Playground/LexicalPlayground/ToolbarPlugin.swift +++ b/Playground/LexicalPlayground/ToolbarPlugin.swift @@ -11,6 +11,7 @@ import LexicalLinkPlugin import LexicalInlineImagePlugin import LexicalListPlugin import UIKit +import SelectableDecoratorNode public class ToolbarPlugin: Plugin { private var _toolbar: UIToolbar @@ -266,6 +267,9 @@ public class ToolbarPlugin: Plugin { UIAction(title: "Insert Sample Image", image: UIImage(systemName: "photo"), handler: { [weak self] (_) in self?.insertSampleImage() }), + UIAction(title: "Insert Selectable Image", image: UIImage(systemName: "photo"), handler: { [weak self] (_) in + self?.insertSelectableImage() + }), ] } @@ -329,6 +333,18 @@ public class ToolbarPlugin: Plugin { } } + private func insertSelectableImage() { + guard let url = Bundle.main.url(forResource: "lexical-logo", withExtension: "png") else { + return + } + try? editor?.update { + let imageNode = SelectableImageNode(url: url.absoluteString, size: CGSize(width: 300, height: 300), sourceID: "") + if let selection = try getSelection() { + _ = try selection.insertNodes(nodes: [imageNode], selectStart: false) + } + } + } + // MARK: - Link handling internal func showLinkEditor() { diff --git a/Plugins/LexicalInlineImagePlugin/LexicalInlineImagePlugin/Nodes/SelectableImageNode.swift b/Plugins/LexicalInlineImagePlugin/LexicalInlineImagePlugin/Nodes/SelectableImageNode.swift new file mode 100644 index 00000000..8515d29a --- /dev/null +++ b/Plugins/LexicalInlineImagePlugin/LexicalInlineImagePlugin/Nodes/SelectableImageNode.swift @@ -0,0 +1,122 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import AVFoundation +import Foundation +import Lexical +import UIKit +import SelectableDecoratorNode + +extension NodeType { + static let selectableImage = NodeType(rawValue: "selectableImage") +} + +public class SelectableImageNode: SelectableDecoratorNode { + var url: URL? + var size = CGSize.zero + var sourceID: String = "" + + public required init(url: String, size: CGSize, sourceID: String, key: NodeKey? = nil) { + super.init(key) + + self.url = URL(string: url) + self.size = size + self.type = NodeType.image + self.sourceID = sourceID + } + + required init(_ key: NodeKey? = nil) { + super.init(key) + + self.type = NodeType.image + } + + public required init(from decoder: Decoder) throws { + try super.init(from: decoder) + + self.type = NodeType.image + } + + override public func encode(to encoder: Encoder) throws { + try super.encode(to: encoder) + } + + override public func clone() -> Self { + Self(url: url?.absoluteString ?? "", size: size, sourceID: sourceID, key: key) + } + + override public func createContentView() -> UIImageView { + let imageView = createImageView() + loadImage(imageView: imageView) + return imageView + } + + override open func decorateContentView(view: UIView, wrapper: SelectableDecoratorView) { + if let view = view as? UIImageView { + loadImage(imageView: view) + } + } + + public func getURL() -> String? { + let latest = getLatest() + return latest.url?.absoluteString + } + + public func setURL(_ url: String) throws { + try errorOnReadOnly() + + try getWritable().url = URL(string: url) + } + + public func getSourceID() -> String? { + let latest = getLatest() + return latest.sourceID + } + + public func setSourceID(_ sourceID: String) throws { + try errorOnReadOnly() + + try getWritable().sourceID = sourceID + } + + private func createImageView() -> UIImageView { + let view = UIImageView(frame: CGRect(origin: CGPoint.zero, size: size)) + view.isUserInteractionEnabled = true + + view.backgroundColor = .lightGray + + return view + } + + private func loadImage(imageView: UIImageView) { + guard let url else { return } + + URLSession.shared.dataTask(with: url) { (data, response, error) in + if error != nil { + return + } + + guard let data else { + return + } + + DispatchQueue.main.async { + imageView.image = UIImage(data: data) + } + }.resume() + } + + let maxImageHeight: CGFloat = 600.0 + + override open func sizeForDecoratorView(textViewWidth: CGFloat, attributes: [NSAttributedString.Key: Any]) -> CGSize { + + if size.width <= textViewWidth { + return size + } + return AVMakeRect(aspectRatio: size, insideRect: CGRect(x: 0, y: 0, width: textViewWidth, height: maxImageHeight)).size + } +} diff --git a/Plugins/SelectableDecoratorNode/SelectableDecoratorNode/SelectableDecoratorNode.swift b/Plugins/SelectableDecoratorNode/SelectableDecoratorNode/SelectableDecoratorNode.swift new file mode 100644 index 00000000..64cf0dc0 --- /dev/null +++ b/Plugins/SelectableDecoratorNode/SelectableDecoratorNode/SelectableDecoratorNode.swift @@ -0,0 +1,43 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import UIKit +import Lexical + +open class SelectableDecoratorNode: DecoratorNode { + + // if you're using SelectableDecoratorNode, override `createContentView()` instead of `createView()` + override public final func createView() -> UIView { + guard let editor = getActiveEditor() else { + fatalError() // TODO: refactor decorator API to throws + } + let contentView = createContentView() + let wrapper = SelectableDecoratorView(frame: .zero) + wrapper.contentView = contentView + wrapper.editor = editor + wrapper.nodeKey = getKey() + try? wrapper.setUpListeners() + return wrapper + } + + // if you're using SelectableDecoratorNode, override `decorateContentView()` instead of `decorate()` + override public final func decorate(view: UIView) { + guard let view = view as? SelectableDecoratorView, let contentView = view.contentView else { + return // TODO: refactor decorator API to throws + } + decorateContentView(view: contentView, wrapper: view) + } + + open func createContentView() -> UIView { + fatalError("createContentView: base method not extended") + } + + open func decorateContentView(view: UIView, wrapper: SelectableDecoratorView) { + fatalError("decorateContentView: base method not extended") + } + +} diff --git a/Plugins/SelectableDecoratorNode/SelectableDecoratorNode/SelectableDecoratorView.swift b/Plugins/SelectableDecoratorNode/SelectableDecoratorNode/SelectableDecoratorView.swift new file mode 100644 index 00000000..56b0a7ee --- /dev/null +++ b/Plugins/SelectableDecoratorNode/SelectableDecoratorNode/SelectableDecoratorView.swift @@ -0,0 +1,91 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import UIKit +import Lexical + +public class SelectableDecoratorView: UIView { + public weak var editor: Editor? + public var nodeKey: NodeKey? + + public var contentView: UIView? { + didSet { + if let oldValue, oldValue != contentView { + oldValue.removeFromSuperview() + } + if let contentView { + addSubview(contentView) + contentView.frame = self.bounds + contentView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + } + } + } + + var updateListener: Editor.RemovalHandler? + var gestureRecognizer: UITapGestureRecognizer? + var borderView: UIView = UIView(frame: .zero) + + internal func setUpListeners() throws { + guard let editor, let nodeKey, gestureRecognizer == nil else { + throw LexicalError.invariantViolation("expected editor and node key by now") + } + updateListener = editor.registerUpdateListener() { [weak self] activeEditorState, previousEditorState, dirtyNodes in + try? activeEditorState.read { + let selection = try getSelection() + if let selection = selection as? NodeSelection { + let nodes = try selection.getNodes().map { node in + node.getKey() + } + self?.setDrawsSelectionBorder(nodes.contains(nodeKey)) + } else { + self?.setDrawsSelectionBorder(false) + } + } + } + + let gestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(tapReceived(sender:))) + self.addGestureRecognizer(gestureRecognizer) + self.gestureRecognizer = gestureRecognizer + + addSubview(borderView) + borderView.frame = self.bounds + borderView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + borderView.isUserInteractionEnabled = false + borderView.layer.borderColor = UIColor.red.cgColor + borderView.layer.borderWidth = 2.0 + borderView.isHidden = true + } + + @objc private func tapReceived(sender: UITapGestureRecognizer) { + if sender.state == .ended { + try? editor?.update { + var selection = try getSelection() + if !(selection is NodeSelection) { + let nodeSelection = NodeSelection(nodes: Set()) + getActiveEditorState()?.selection = nodeSelection + selection = nodeSelection + } + guard let selection = selection as? NodeSelection, let nodeKey else { + throw LexicalError.invariantViolation("Expected node selection by now") + } + selection.add(key: nodeKey) + } + } + } + + private var drawsSelectionBorder: Bool = false + private func setDrawsSelectionBorder(_ isSelected: Bool) { + self.drawsSelectionBorder = isSelected + borderView.isHidden = !isSelected + } + + deinit { + if let updateListener { + updateListener() + } + } +}