Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions Lexical/Core/Editor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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 {
Expand Down
13 changes: 6 additions & 7 deletions Lexical/Core/Events.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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}" {
Expand Down Expand Up @@ -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")
}

Expand Down
2 changes: 1 addition & 1 deletion Lexical/Core/Nodes/DecoratorNode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import UIKit

- ``createView()``
- ``decorate(view:)``
- ``sizeForDecoratorView(textViewWidth:)``
- ``sizeForDecoratorView(textViewWidth:attributes:)``

### Optional methods to override

Expand Down
8 changes: 7 additions & 1 deletion Lexical/Core/Reconciler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
46 changes: 46 additions & 0 deletions Lexical/Core/Selection/BaseSelection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
25 changes: 25 additions & 0 deletions Lexical/Core/Selection/GridSelection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import Foundation
import UIKit

public class GridSelection: BaseSelection {

public func getTextContent() throws -> String {
return ""
}
Expand Down Expand Up @@ -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 {
Expand Down
57 changes: 57 additions & 0 deletions Lexical/Core/Selection/NodeSelection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import UIKit

public class NodeSelection: BaseSelection {


public var nodes: Set<NodeKey>
public var dirty: Bool = false

Expand All @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
12 changes: 6 additions & 6 deletions Lexical/Core/Selection/RangeSelection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -705,7 +705,7 @@ public class RangeSelection: BaseSelection {

// MARK: - Internal

internal func insertParagraph() throws {
public func insertParagraph() throws {
if !isCollapsed() {
try removeText()
}
Expand Down Expand Up @@ -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()

Expand Down
5 changes: 5 additions & 0 deletions Lexical/Core/Selection/SelectionUtils.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
30 changes: 30 additions & 0 deletions Lexical/Documentation.docc/BaseSelection.md
Original file line number Diff line number Diff line change
@@ -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:)``

2 changes: 2 additions & 0 deletions Lexical/Documentation.docc/Lexical.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Lexical for iOS is an extensible text rendering and editing framework written in
- ``Editor``
- ``EditorState``
- ``EditorConfig``
- ``BaseSelection``
- ``RangeSelection``
- ``Theme``
- ``Plugin``
Expand Down Expand Up @@ -68,6 +69,7 @@ Lexical for iOS is an extensible text rendering and editing framework written in

- ``BaseSelection``
- ``SelectionType``
- ``RangeSelection``
- ``NodeSelection``
- ``GridSelection``
- ``Point``
Expand Down
2 changes: 1 addition & 1 deletion Lexical/LexicalView/LexicalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading