Skip to content
This repository has been archived by the owner on Aug 11, 2024. It is now read-only.

TextKit 2 - Basic Integration #421

Merged
merged 18 commits into from
Mar 2, 2023
Merged
Show file tree
Hide file tree
Changes from 13 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
Original file line number Diff line number Diff line change
Expand Up @@ -123,16 +123,43 @@ struct MarkupTextViewRepresentable: UIViewRepresentable {
)
)
}

// This allows us to intercept touches on embedded transclude blocks
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
bfollington marked this conversation as resolved.
Show resolved Hide resolved
guard let touch = touches.first else { return }
let tapPoint = touch.location(in: self)

guard let textLayoutManager = self.textLayoutManager else {
MarkupTextViewRepresentable.logger.warning("Could not access textLayoutManager")
return
}

guard let textContentStorage = textLayoutManager.textContentManager as? NSTextContentStorage else {
MarkupTextViewRepresentable.logger.warning("Could not access textContentStorage")
return
}

// Did tap a text element?
if let textElement = textLayoutManager.textLayoutFragment(for: tapPoint)?.textElement {
let content = textContentStorage.attributedString(for: textElement)

// TODO: check for whether this tap should navigate to a link
MarkupTextViewRepresentable.logger.debug("Tapped: \(String(describing: content?.string))")
// Calling super preserves default behaviour
super.touchesBegan(touches, with: event)
}
}
}

// MARK: Coordinator
class Coordinator: NSObject, UITextViewDelegate, NSTextStorageDelegate {
class Coordinator: NSObject, UITextViewDelegate, NSTextContentStorageDelegate, NSTextContentManagerDelegate, NSTextLayoutManagerDelegate {
/// Is event happening during updateUIView?
/// Used to avoid setting properties in events during view updates, as
/// that would cause feedback cycles where an update triggers an event,
/// which triggers an update, which triggers an event, etc.
var isUIViewUpdating: Bool
var representable: MarkupTextViewRepresentable
var renderTranscludeBlocks: Bool = false
bfollington marked this conversation as resolved.
Show resolved Hide resolved

init(
representable: MarkupTextViewRepresentable
Expand All @@ -141,37 +168,6 @@ struct MarkupTextViewRepresentable: UIViewRepresentable {
self.representable = representable
}

/// NSTextStorageDelegate method
/// Handle markup rendering, just before processEditing is fired.
/// It is important that we render markup in `willProcessEditing`
/// because it happens BEFORE font substitution. Rendering before font
/// substitution gives the OS a chance to replace fonts for things like
/// Emoji or Unicode characters when your font does not support them.
/// See:
/// https://github.com/gordonbrander/subconscious/wiki/nstextstorage-font-substitution-and-missing-text
///
/// 2022-03-17 Gordon Brander
gordonbrander marked this conversation as resolved.
Show resolved Hide resolved
func textStorage(
_ textStorage: NSTextStorage,
willProcessEditing: NSTextStorage.EditActions,
range: NSRange,
changeInLength: Int
) {
MarkupTextViewRepresentable.logger.debug(
"textStorage: render markup attributes"
)
textStorage.setAttributes(
[:],
range: NSRange(
textStorage.string.startIndex...,
in: textStorage.string
)
)
// Render markup on TextStorage (which is an NSMutableString)
// using closure set on view (representable)
self.representable.renderAttributesOf(textStorage)
}

/// Handle link taps
func textView(
_ textView: UITextView,
Expand Down Expand Up @@ -243,6 +239,52 @@ struct MarkupTextViewRepresentable: UIViewRepresentable {
)
)
}

// MARK: - NSTextLayoutManagerDelegate

func textLayoutManager(
_ textLayoutManager: NSTextLayoutManager,
textLayoutFragmentFor location: NSTextLocation,
in textElement: NSTextElement
) -> NSTextLayoutFragment {
let baseLayoutFragment = NSTextLayoutFragment(textElement: textElement, range: textElement.elementRange)

guard let textContentStorage = textLayoutManager.textContentManager as? NSTextContentStorage else {
MarkupTextViewRepresentable.logger.warning("Could not access textContentStorage")
return baseLayoutFragment
}

if let text = textContentStorage.attributedString(for: textElement)?.string {
let sub = Subtext(markup: text)

// Only render transcludes for a single slug in a single block
if renderTranscludeBlocks && sub.slugs.count == 1 && sub.blocks.count == 1 {
bfollington marked this conversation as resolved.
Show resolved Hide resolved
let layoutFragment = TranscludeBlockLayoutFragment(textElement: textElement, range: textElement.elementRange)
layoutFragment.text = text

return layoutFragment
}
}

return baseLayoutFragment
}

// MARK: - NSTextContentStorageDelegate

func textContentStorage(_ textContentStorage: NSTextContentStorage, textParagraphWith range: NSRange) -> NSTextParagraph? {
guard let originalText = textContentStorage.textStorage?.attributedSubstring(from: range) else {
MarkupTextViewRepresentable.logger.warning("textContentStorage: could not access attributedSubstring")
return nil
}

let textWithDisplayAttributes = NSMutableAttributedString(attributedString: originalText)
MarkupTextViewRepresentable.logger.debug(
"textContentStorage: render markup attributes"
)
self.representable.renderAttributesOf(textWithDisplayAttributes)

return NSTextParagraph(attributedString: textWithDisplayAttributes)
}
}

static var logger = Logger(
Expand Down Expand Up @@ -271,14 +313,23 @@ struct MarkupTextViewRepresentable: UIViewRepresentable {
// MARK: makeUIView
func makeUIView(context: Context) -> MarkupTextView {
Self.logger.debug("makeUIView")
let view = MarkupTextView()

let textLayoutManager = NSTextLayoutManager()
let textContentStorage = NSTextContentStorage()
let textContainer = NSTextContainer()
textContentStorage.delegate = context.coordinator
textLayoutManager.delegate = context.coordinator

textContentStorage.addTextLayoutManager(textLayoutManager)

textLayoutManager.textContainer = textContainer

let view = MarkupTextView(frame: self.frame, textContainer: textContainer)

// Coordinator is both an UITextViewDelegate
// and an NSTextStorageDelegate.
// Set delegate on textview (coordinator)
view.delegate = context.coordinator
// Set delegate on textstorage (coordinator)
view.textStorage.delegate = context.coordinator

// Set inner padding
view.textContainerInset = self.textContainerInset
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
//
// TranscludeBlockLayoutFragment.swift
// Subconscious (iOS)
//
// Created by Ben Follington on 28/2/2023.
//

/*
Copyright © 2022 Apple Inc.

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/

import SwiftUI

#if os(iOS)
import UIKit
#else
import Cocoa
#endif
import CoreGraphics

struct EmbeddedTranscludePreview: View {
var label: String = "Subconscious"

var body: some View {
VStack(alignment: .leading) {
Text("title")
Text(label)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
.padding(4)
.frame(maxWidth: .infinity, maxHeight: 128) // TODO: share constant for all instances of 128
.background(.white)
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(.purple, lineWidth: 2)
)
.padding(2)
}
}

extension UIView {
func asImage() -> UIImage {
let renderer = UIGraphicsImageRenderer(bounds: bounds)
let img = renderer.image { rendererContext in
rendererContext.cgContext.saveGState()

// SwiftUI views render upside down by default, flip them
let flipVertical: CGAffineTransform = CGAffineTransform(a: 1, b: 0, c: 0, d: -1, tx: 0, ty: bounds.height )
rendererContext.cgContext.concatenate(flipVertical)

layer.render(in: rendererContext.cgContext)

rendererContext.cgContext.restoreGState()
}

return img
}
}

class TranscludeBlockLayoutFragment: NSTextLayoutFragment {
// Max height constraint
let SLASHLINK_PREVIEW_HEIGHT = 128.0
var text: String?

override var leadingPadding: CGFloat { return 10 }
override var trailingPadding: CGFloat { return 10 }
override var topMargin: CGFloat { return 0 }
override var bottomMargin: CGFloat { return 0 }

private var img: UIImage?

private func render() {
let v = EmbeddedTranscludePreview(label: text ?? "Testing")
// Host our SwiftUI view within a UIKit view
let hosted = UIHostingController(rootView: v)
guard let view = hosted.view else {
return
}

view.translatesAutoresizingMaskIntoConstraints = false

// We have to mount the view before it will actually do layout calculations
UIApplication.shared.windows.first?.rootViewController?.view.addSubview(hosted.view)
bfollington marked this conversation as resolved.
Show resolved Hide resolved

// Ideally here is where we would dynamically adjust the height of the rendered card
// However there doesn't seem to be a way to get the "preferred" size of the child content, it always tries to expand to fill the space provided
// This might be due to the underlying UIKit constraint system
let size = hosted.sizeThatFits(in: CGSize(width: containerWidth(), height: SLASHLINK_PREVIEW_HEIGHT))
hosted.view.frame = CGRect(origin: CGPoint(x: 0, y: SLASHLINK_PREVIEW_HEIGHT), size: CGSize(width: containerWidth(), height: size.height))
hosted.view.bounds = CGRect(origin: CGPoint(x: 0, y: 0), size: CGSize(width: containerWidth(), height: size.height ))
hosted.view.backgroundColor = .clear

let img = view.asImage()
hosted.view.removeFromSuperview()

// Cache for reuse in layout calculations
self.img = img

// Reflow the document layout to include the subview dimensions
invalidateLayout()
}

private func containerWidth() -> CGFloat {
return self.textLayoutManager!.textContainer!.size.width - leadingPadding - trailingPadding
}

// Determines how this flows around text
override var layoutFragmentFrame: CGRect {
let parent = super.layoutFragmentFrame
let r = CGRect(origin: parent.origin, size: CGSize(width: parent.width, height: parent.height + (self.img?.size.height ?? 0)))
return r
}

// Determines the full drawing bounds, should be larger than layoutFragmentFrame
override var renderingSurfaceBounds: CGRect {
let w = containerWidth()

let size = super.renderingSurfaceBounds.union(CGRect(x: 0, y: 0, width: w, height: SLASHLINK_PREVIEW_HEIGHT))
return size
}

private func withTranslation(x: CGFloat, y: CGFloat, ctx: CGContext, perform: (CGContext) -> Void) {
ctx.translateBy(x: x, y: y)
perform(ctx)
ctx.translateBy(x: -x, y: -y)
}

override func draw(at renderingOrigin: CGPoint, in ctx: CGContext) {
render()
ctx.saveGState()

if let img = self.img {
let height = img.size.height
withTranslation(x: leadingPadding, y: super.layoutFragmentFrame.height, ctx: ctx) { ctx in
ctx.draw(img.cgImage!, in: CGRect(x: 0, y: 0, width: img.size.width, height: height))
// DEBUG: Render border around the cached view image
// ctx.stroke(CGRect(origin: renderingSurfaceBounds.origin, size: img.size))
}
}

// DEBUG: render border around entire draw surface
// withTranslation(x: leadingPadding, y: 0, ctx: ctx) { ctx in
// ctx.stroke(CGRect(origin: renderingSurfaceBounds.origin, size: renderingSurfaceBounds.size))
// }

ctx.restoreGState()

// Draw the text on top.
super.draw(at: renderingOrigin, in: ctx)
}
}
12 changes: 12 additions & 0 deletions xcode/Subconscious/Subconscious.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
/* Begin PBXBuildFile section */
B54B922828E669D6003ACA1F /* MementoGeist.swift in Sources */ = {isa = PBXBuildFile; fileRef = B54B922728E669D6003ACA1F /* MementoGeist.swift */; };
B575834528ED8D9100F6EE88 /* combo.json in Resources */ = {isa = PBXBuildFile; fileRef = B575834428ED8D9100F6EE88 /* combo.json */; };
B584814929AD82270033F434 /* TranscludeBlockLayoutFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = B584814829AD82270033F434 /* TranscludeBlockLayoutFragment.swift */; };
B58FE48A28DED93F00E000CC /* ComboGeist.swift in Sources */ = {isa = PBXBuildFile; fileRef = B58FE48928DED93F00E000CC /* ComboGeist.swift */; };
B58FE48C28DED9B600E000CC /* StoryCombo.swift in Sources */ = {isa = PBXBuildFile; fileRef = B58FE48B28DED9B600E000CC /* StoryCombo.swift */; };
B58FE48E28DEDAEA00E000CC /* StoryComboView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B58FE48D28DEDAEA00E000CC /* StoryComboView.swift */; };
Expand Down Expand Up @@ -341,6 +342,7 @@
/* Begin PBXFileReference section */
B54B922728E669D6003ACA1F /* MementoGeist.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MementoGeist.swift; sourceTree = "<group>"; };
B575834428ED8D9100F6EE88 /* combo.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = combo.json; sourceTree = "<group>"; };
B584814829AD82270033F434 /* TranscludeBlockLayoutFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscludeBlockLayoutFragment.swift; sourceTree = "<group>"; };
B58FE48928DED93F00E000CC /* ComboGeist.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ComboGeist.swift; sourceTree = "<group>"; };
B58FE48B28DED9B600E000CC /* StoryCombo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryCombo.swift; sourceTree = "<group>"; };
B58FE48D28DEDAEA00E000CC /* StoryComboView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryComboView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -591,6 +593,14 @@
/* End PBXFrameworksBuildPhase section */

/* Begin PBXGroup section */
B5D8D2FD29AED1AE0011D820 /* Transclude */ = {
isa = PBXGroup;
children = (
B584814829AD82270033F434 /* TranscludeBlockLayoutFragment.swift */,
);
path = Transclude;
sourceTree = "<group>";
};
B80057E927DC355E002C0129 /* SubconsciousTests */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -960,6 +970,7 @@
B8EC568426F41A2C00AC64E5 /* Common */ = {
isa = PBXGroup;
children = (
B5D8D2FD29AED1AE0011D820 /* Transclude */,
B8CBAFA5299449810079107E /* Audience */,
B8AC648E278F7E7B0099E96B /* BackLabelStyle.swift */,
B8545F0C2970577600BC4EA1 /* BacklinkReacts.swift */,
Expand Down Expand Up @@ -1399,6 +1410,7 @@
B8CC434927A0CA8D0079D2F9 /* AnimationUtilities.swift in Sources */,
B89966C728B6EE2300DF1F8C /* Notebook.swift in Sources */,
B88B1CE7298EEC240062CB7F /* GatewayURLSettingsView.swift in Sources */,
B584814929AD82270033F434 /* TranscludeBlockLayoutFragment.swift in Sources */,
B8B4251228FDE7780081B8D5 /* Mapping.swift in Sources */,
B8AE34AD276A9CDB00777FF0 /* PrimaryButtonStyle.swift in Sources */,
B8A41D4E2811E81E0096D2E7 /* WikilinkBarView.swift in Sources */,
Expand Down