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

Large diffs are not rendered by default.

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions Sources/Navigator/EPUB/EPUBNavigatorViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1101,6 +1101,9 @@ extension EPUBNavigatorViewController: EPUBSpreadViewDelegate {
Task {
var event = event
event.location = view.convert(event.location, from: spreadView)
if let targetElement = event.targetElement {
event.targetElement?.frame = view.convert(targetElement.frame, from: spreadView)
}
_ = await inputObservers.didReceive(event)
}
}
Expand Down
83 changes: 82 additions & 1 deletion Sources/Navigator/EPUB/EPUBSpreadView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,11 @@ class EPUBSpreadView: UIView, Loggable, PageView {
}

event.location = convertPointToNavigatorSpace(event.location)

if let targetElement = targetElement(from: json["targetElement"]) {
event.targetElement = targetElement
}

delegate?.spreadView(self, didReceive: event)
}

Expand Down Expand Up @@ -709,11 +714,87 @@ private extension PointerEvent {
modifiers: KeyModifiers(json: json)
)
// FIXME:
// targetElement = dict["targetElement"] as? String ?? ""
// interactiveElement = dict["interactiveElement"] as? String
}
}

// MARK: - Target Element Extraction

extension EPUBSpreadView {
/// Parses the target element JSON produced by `extractTargetElement()` in
/// gestures.js and builds a `PointerEvent.TargetElement` with coordinates
/// converted to the spread view's coordinate space.
func targetElement(from json: Any?) -> PointerEvent.TargetElement? {
guard
let dict = json as? [String: Any],
let tag = dict["tag"] as? String,
let frameDict = dict["frame"] as? [String: Any],
let x = frameDict["x"] as? Double,
let y = frameDict["y"] as? Double,
let width = frameDict["width"] as? Double,
let height = frameDict["height"] as? Double,
let publicationBaseURL = viewModel.publicationBaseURL
else {
return nil
}

let frame = convertRectToNavigatorSpace(
CGRect(x: x, y: y, width: width, height: height)
)

// Relativize the src URL against the publication base URL so it
// becomes a publication-relative href. External URLs (http://) or
// already-relative URLs fall back to the raw value.
let src: String? = (dict["src"] as? String).flatMap { raw in
guard let srcURL = URL(string: raw) else { return raw }
return publicationBaseURL.relativize(srcURL)?.string ?? raw
}

// Look up the Link in the publication manifest so the client gets
// full metadata (media type, etc.). Falls back to a plain Link.
let embeddedLink: Link = src.flatMap { href in
URL(string: href).flatMap { viewModel.publication.linkWithHREF($0) }
} ?? Link(href: src ?? "")

// Build a locator pointing to the element inside the current XHTML resource.
let resourceLink = spread.first.link
var locations = Locator.Locations()
if let cssSelector = dict["cssSelector"] as? String {
locations.otherLocations["cssSelector"] = .string(cssSelector)
}
let locator = Locator(
href: resourceLink.url(),
mediaType: resourceLink.mediaType ?? .xhtml,
locations: locations
)

// Map the HTML tag to the appropriate ContentElement subtype.
let content: (any ContentElement)? = switch tag {
case "img", "svg":
ImageContentElement(
locator: locator,
embeddedLink: embeddedLink,
caption: dict["alt"] as? String
)
case "audio":
AudioContentElement(
locator: locator,
embeddedLink: embeddedLink
)
case "video":
VideoContentElement(
locator: locator,
embeddedLink: embeddedLink
)
default:
nil
}

guard let content else { return nil }
return PointerEvent.TargetElement(frame: frame, content: content)
}
}

private extension MouseButtons {
init(json: [String: Any]) {
self.init()
Expand Down
80 changes: 79 additions & 1 deletion Sources/Navigator/EPUB/Scripts/src/gestures.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,8 @@ function onPointerEvent(phase, event) {
x: point.x,
y: point.y,
buttons: event.buttons,
targetElement: event.target.outerHTML,
interactiveElement: findNearestInteractiveElement(event.target),
targetElement: extractTargetElement(event.target),
option: event.altKey,
control: event.ctrlKey,
shift: event.shiftKey,
Expand All @@ -103,3 +103,81 @@ function onPointerEvent(phase, event) {
// event.stopPropagation();
// event.preventDefault();
}

/// Extracts metadata about the target element for gesture handling.
///
/// Returns an object with the element's bounding rectangle, tag name, source
/// URL, alt text, and a CSS selector. This information is used on the Swift
/// side to build the appropriate `ContentElement`.
function extractTargetElement(element) {
if (!element || !element.getBoundingClientRect) {
return null;
}

let mediaElement = findNearestMediaElement(element);
if (!mediaElement) {
return null;
}

let rect = mediaElement.getBoundingClientRect();
let adjustedOrigin = adjustPointToViewport({ x: rect.left, y: rect.top });
let adjustedEnd = adjustPointToViewport({
x: rect.left + rect.width,
y: rect.top + rect.height,
});

return {
tag: mediaElement.tagName.toLowerCase(),
src: mediaElement.src || mediaElement.getAttribute("href") || null,
frame: {
x: adjustedOrigin.x,
y: adjustedOrigin.y,
width: adjustedEnd.x - adjustedOrigin.x,
height: adjustedEnd.y - adjustedOrigin.y,
},
alt: mediaElement.getAttribute("alt") || null,
cssSelector: getCSSSelector(mediaElement),
};
}

/// Walks up the DOM tree from the given element to find the nearest media
/// element (img, svg, audio, video).
function findNearestMediaElement(element) {
const mediaTags = ["img", "svg", "audio", "video"];
let current = element;
while (current && current !== document.documentElement) {
if (mediaTags.includes(current.tagName.toLowerCase())) {
return current;
}
current = current.parentElement;
}
return null;
}

/// Builds a CSS selector that uniquely identifies the given element within
/// the document.
function getCSSSelector(element) {
if (element.id) {
return "#" + CSS.escape(element.id);
}

let parts = [];
let current = element;
while (current && current !== document.documentElement) {
let selector = current.tagName.toLowerCase();
if (current.id) {
parts.unshift("#" + CSS.escape(current.id));
break;
}
let sibling = current;
let nth = 1;
while ((sibling = sibling.previousElementSibling)) {
if (sibling.tagName === current.tagName) nth++;
}
selector += ":nth-of-type(" + nth + ")";
parts.unshift(selector);
current = current.parentElement;
}

return parts.join(" > ");
}
23 changes: 23 additions & 0 deletions Sources/Navigator/Input/Pointer/PointerEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
//

import Foundation
import ReadiumShared

/// Represents a pointer event (e.g. touch, mouse) emitted by a navigator.
public struct PointerEvent: Equatable {
Expand All @@ -20,6 +21,28 @@ public struct PointerEvent: Equatable {
/// Key modifiers pressed alongside the pointer.
public var modifiers: KeyModifiers

/// The content element under the pointer, if recognized by the navigator.
public var targetElement: TargetElement?

/// A content element targeted by a pointer event, paired with its
/// on-screen frame.
public struct TargetElement: Equatable {
/// Frame of the element relative to the navigator's view.
public var frame: CGRect

/// The content element under the pointer.
public var content: any ContentElement

public init(frame: CGRect, content: any ContentElement) {
self.frame = frame
self.content = content
}

public static func == (lhs: Self, rhs: Self) -> Bool {
lhs.frame == rhs.frame && lhs.content.isEqualTo(rhs.content)
}
}

/// Phase of a pointer event.
public enum Phase: Equatable, CustomStringConvertible {
/// Fired when a pointer becomes active.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
//
// Copyright 2026 Readium Foundation. All rights reserved.
// Use of this source code is governed by the BSD-style license
// available in the top-level LICENSE file of the project.
//

import ReadiumNavigator
import ReadiumShared
import UIKit

/// A simple image preview presented as a sheet.
///
/// Displays the image and basic metadata (href, caption) to demonstrate
/// the Readium `ImageContentElement` API.
final class ImagePreviewViewController: UIViewController {
private let image: ImageContentElement
private let publication: Publication
private let imageView = UIImageView()
private let linkLabel = UILabel()

init(image: ImageContentElement, publication: Publication) {
self.image = image
self.publication = publication

super.init(nibName: nil, bundle: nil)

title = image.caption
navigationItem.rightBarButtonItem = UIBarButtonItem(
systemItem: .done,
primaryAction: UIAction { [weak self] _ in
self?.dismiss(animated: true)
}
)
}

@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

override func viewDidLoad() {
super.viewDidLoad()

view.backgroundColor = .systemBackground

imageView.contentMode = .scaleAspectFit
imageView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(imageView)

linkLabel.text = image.embeddedLink.href
linkLabel.textAlignment = .center
linkLabel.textColor = .secondaryLabel
linkLabel.numberOfLines = 0
linkLabel.font = .preferredFont(forTextStyle: .footnote)
linkLabel.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(linkLabel)

NSLayoutConstraint.activate([
imageView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16),
imageView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
imageView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
imageView.heightAnchor.constraint(lessThanOrEqualTo: view.heightAnchor, multiplier: 0.6),

linkLabel.topAnchor.constraint(equalTo: imageView.bottomAnchor, constant: 12),
linkLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
linkLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
])

Task { @MainActor in
let link = image.embeddedLink
if let resource = publication.get(link),
let imageData = try? await resource.read().get()
{
imageView.image = UIImage(data: imageData)
}
}
}
}
25 changes: 25 additions & 0 deletions TestApp/Sources/Reader/Common/VisualReaderViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,19 @@ class VisualReaderViewController<N: UIViewController & Navigator>: ReaderViewCon
)
directionalNavigationAdapter?.bind(to: navigator)

// Present an image preview when tapping an image element.
navigator.addObserver(.tap { [weak self] event in
guard
let self,
let targetElement = event.targetElement,
targetElement.content is ImageContentElement
else {
return false
}
self.presentImagePreview(targetElement)
return true
})

// Clear the current search highlight on tap.
navigator.addObserver(.activate { [weak self] _ in
guard
Expand Down Expand Up @@ -208,6 +221,18 @@ class VisualReaderViewController<N: UIViewController & Navigator>: ReaderViewCon

// MARK: - VisualNavigatorDelegate

private func presentImagePreview(_ targetElement: PointerEvent.TargetElement) {
guard let image = targetElement.content as? ImageContentElement else { return }
let viewer = ImagePreviewViewController(
image: image,
publication: publication
)
let navController = UINavigationController(rootViewController: viewer)
navController.modalPresentationStyle = .fullScreen
navController.modalTransitionStyle = .crossDissolve
present(navController, animated: true)
}

override func navigator(_ navigator: Navigator, locationDidChange locator: Locator) {
super.navigator(navigator, locationDidChange: locator)

Expand Down
Loading