Tokamak/Sources/TokamakDOM/DOMFiberRenderer.swift

347 lines
11 KiB
Swift

// Copyright 2021 Tokamak contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Created by Carson Katri on 2/7/22.
//
import Foundation
import JavaScriptEventLoop
import JavaScriptKit
import OpenCombineJS
import OpenCombineShim
@_spi(TokamakCore)
import TokamakCore
@_spi(TokamakStaticHTML)
import TokamakStaticHTML
public final class DOMElement: FiberElement {
var reference: JSObject?
public struct Content: FiberElementContent {
let tag: String
let namespace: String?
let attributes: [HTMLAttribute: String]
let innerHTML: String?
let listeners: [String: Listener]
let debugData: [String: ConvertibleToJSValue]
public static func == (lhs: Self, rhs: Self) -> Bool {
lhs.tag == rhs.tag
&& lhs.namespace == rhs.namespace
&& lhs.attributes == rhs.attributes
&& lhs.innerHTML == rhs.innerHTML
}
}
public var content: Content
public init(from content: Content) {
self.content = content
}
public func update(with content: Content) {
self.content = content
}
}
public extension DOMElement.Content {
init<V>(from primitiveView: V, useDynamicLayout: Bool) where V: View {
guard let primitiveView = primitiveView as? HTMLConvertible else { fatalError() }
tag = primitiveView.tag
namespace = primitiveView.namespace
attributes = primitiveView.attributes(useDynamicLayout: useDynamicLayout)
innerHTML = primitiveView.innerHTML
if let primitiveView = primitiveView as? DOMNodeConvertible {
listeners = primitiveView.listeners
} else {
listeners = [:]
}
debugData = [
"view": String(reflecting: V.self),
]
}
}
protocol DOMNodeConvertible: HTMLConvertible {
var listeners: [String: Listener] { get }
}
public struct DOMFiberRenderer: FiberRenderer {
public let rootElement: DOMElement
private let resizeObserver: JSObject?
public let sceneSize: CurrentValueSubject<CGSize, Never>
public let useDynamicLayout: Bool
public var defaultEnvironment: EnvironmentValues {
var environment = EnvironmentValues()
environment[_ColorSchemeKey.self] = .light
environment._defaultAppStorage = LocalStorage.standard
_DefaultSceneStorageProvider.default = SessionStorage.standard
return environment
}
public init(_ rootSelector: String, useDynamicLayout: Bool = true) {
if #available(macOS 10.15, *) {
JavaScriptEventLoop.installGlobalExecutor()
}
guard let reference = document.querySelector!(rootSelector).object else {
fatalError("""
The root element with selector '\(rootSelector)' could not be found. \
Ensure this element exists in your site's index.html file.
""")
}
rootElement = .init(
from: .init(
tag: "",
namespace: nil,
attributes: [:],
innerHTML: nil,
listeners: [:],
debugData: ["view": "root"]
)
)
rootElement.reference = reference
self.useDynamicLayout = useDynamicLayout
if useDynamicLayout {
// Setup the root styles
_ = reference.style.setProperty("margin", "0")
_ = reference.style.setProperty("width", "100vw")
_ = reference.style.setProperty("height", "100vh")
_ = reference.style.setProperty("position", "relative")
let sceneSizePublisher = CurrentValueSubject<CGSize, Never>(
.init(width: body.clientWidth.number!, height: body.clientHeight.number!)
)
sceneSize = sceneSizePublisher
resizeObserver = JSObject.global.ResizeObserver.function!.new(JSClosure { _ in
sceneSizePublisher.send(
.init(width: body.clientWidth.number!, height: body.clientHeight.number!)
)
return .undefined
})
_ = resizeObserver?.observe?(body)
} else {
sceneSize = .init(.zero)
resizeObserver = nil
let style = document.createElement!("style").object!
style.innerHTML = .string(TokamakStaticHTML.tokamakStyles)
_ = document.head.appendChild(style)
}
}
public static func isPrimitive<V>(_ view: V) -> Bool where V: View {
!(view is AnyOptional) &&
(view is HTMLConvertible || view is DOMNodeConvertible)
}
public func visitPrimitiveChildren<Primitive, Visitor>(
_ view: Primitive
) -> ViewVisitorF<Visitor>? where Primitive: View, Visitor: ViewVisitor {
guard let primitive = view as? HTMLConvertible else { return nil }
return primitive.primitiveVisitor(useDynamicLayout: useDynamicLayout)
}
private func createElement(_ element: DOMElement) -> JSObject {
let result: JSObject
if let namespace = element.content.namespace {
result = document.createElementNS!(namespace, element.content.tag).object!
} else {
result = document.createElement!(element.content.tag).object!
}
apply(element.content, to: result)
element.reference = result
return result
}
public func measureText(
_ text: Text,
proposal: ProposedViewSize,
in environment: EnvironmentValues
) -> CGSize {
let element = createElement(.init(from: .init(from: text, useDynamicLayout: true)))
if let width = proposal.width {
_ = element.style.setProperty("maxWidth", "\(width)px")
}
if let height = proposal.height {
_ = element.style.setProperty("maxHeight", "\(height)px")
}
_ = document.body.appendChild(element)
let rect = element.getBoundingClientRect!()
let size = CGSize(
width: rect.width.number ?? 0,
height: rect.height.number ?? 0
)
_ = document.body.removeChild(element)
return size
}
final class ImageCache {
var values = [String: CGSize]()
}
private var imageCache = ImageCache()
private func loadImageSize(src: String, _ onload: @escaping (CGSize) -> ()) {
if let cached = imageCache.values[src] {
return onload(cached)
}
let Image = JSObject.global.Image.function!
let jsImage = Image.new()
jsImage.src = .string(src)
jsImage.onload = JSOneshotClosure { value in
let naturalSize = CGSize(
width: value[0].target.object!.naturalWidth.number!,
height: value[0].target.object!.naturalHeight.number!
)
imageCache.values[src] = naturalSize
onload(naturalSize)
return .undefined
}.jsValue
}
public func measureImage(
_ image: Image,
proposal: ProposedViewSize,
in environment: EnvironmentValues
) -> CGSize {
switch image.provider.resolve(in: environment).storage {
case let .named(name, bundle):
loadImageSize(
src: bundle?
.path(forResource: name, ofType: nil) ?? name
) { naturalSize in
environment.afterReconcile {
image._intrinsicSize = naturalSize
}
}
return .zero
case let .resizable(.named(name, bundle: bundle), _, _):
if proposal == .unspecified {
if let intrinsicSize = image._intrinsicSize {
return intrinsicSize
}
loadImageSize(
src: bundle?
.path(forResource: name, ofType: nil) ?? name
) { naturalSize in
environment.afterReconcile {
image._intrinsicSize = naturalSize
}
}
return .zero
}
return proposal.replacingUnspecifiedDimensions()
default:
return .zero
}
}
private func apply(_ content: DOMElement.Content, to element: JSObject) {
for (attribute, value) in content.attributes {
if attribute.isUpdatedAsProperty {
element[attribute.value] = .string(value)
} else {
_ = element.setAttribute?(attribute.value, value)
}
}
if let innerHTML = content.innerHTML {
element.innerHTML = .string(innerHTML)
}
for (event, action) in content.listeners {
_ = element.addEventListener?(event, JSClosure {
action($0[0].object!)
return .undefined
})
}
#if DEBUG
for (key, value) in content.debugData {
element.dataset.object?[dynamicMember: key] = value.jsValue
}
#endif
}
private func apply(_ geometry: ViewGeometry, to element: JSObject) {
guard useDynamicLayout else { return }
_ = element.style.setProperty("position", "absolute")
_ = element.style.setProperty("width", "\(geometry.dimensions.width)px")
_ = element.style.setProperty("height", "\(geometry.dimensions.height)px")
_ = element.style.setProperty("left", "\(geometry.origin.x)px")
_ = element.style.setProperty("top", "\(geometry.origin.y)px")
}
public func commit(_ mutations: [Mutation<Self>]) {
for mutation in mutations {
switch mutation {
case let .insert(newElement, parent, index):
let element = createElement(newElement)
guard let parentElement = parent.reference ?? rootElement.reference
else { fatalError("The root element was not bound (trying to insert element).") }
if Int(parentElement.children.object?.length.number ?? 0) > index {
_ = parentElement.insertBefore?(element, parentElement.children[index])
} else {
_ = parentElement.appendChild?(element)
}
case let .remove(element, _):
_ = element.reference?.remove?()
case let .replace(parent, previous, replacement):
guard let parentElement = parent.reference ?? rootElement.reference
else { fatalError("The root element was not bound (trying to replace element).") }
guard let previousElement = previous.reference else {
fatalError("The previous element does not exist (trying to replace element).")
}
let replacementElement = createElement(replacement)
_ = parentElement.replaceChild?(previousElement, replacementElement)
case let .update(previous, newContent, geometry):
previous.update(with: newContent)
guard let previousElement = previous.reference
else { fatalError("The element does not exist (trying to update element).") }
apply(newContent, to: previousElement)
// Re-apply geometry as style changes could've overwritten it.
apply(geometry, to: previousElement)
previous.reference = previousElement
case let .layout(element, geometry):
guard let element = element.reference else {
fatalError("The element does not exist (trying to layout).")
}
apply(geometry, to: element)
}
}
}
private let scheduler = JSScheduler()
public func schedule(_ action: @escaping () -> ()) {
scheduler.schedule(options: nil, action)
}
}
extension _PrimitiveButtonStyleBody: DOMNodeConvertible {
public var tag: String { "button" }
public func attributes(useDynamicLayout: Bool) -> [HTMLAttribute: String] {
[:]
}
var listeners: [String: Listener] {
["pointerup": { _ in self.action() }]
}
}