add local webreader for apple apps
This commit is contained in:
@ -145,7 +145,9 @@ struct LinkItemDetailView: View {
|
||||
|
||||
var body: some View {
|
||||
#if os(iOS)
|
||||
if viewModel.item.isPDF {
|
||||
if FeatureFlag.useLocalWebView {
|
||||
WebReaderContainerView(item: viewModel.item)
|
||||
} else if viewModel.item.isPDF {
|
||||
fixedNavBarReader
|
||||
} else {
|
||||
hidingNavBarReader
|
||||
|
||||
@ -0,0 +1,72 @@
|
||||
import Combine
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import Utils
|
||||
import WebKit
|
||||
|
||||
final class WebReaderViewModel: ObservableObject {
|
||||
@Published var isLoading = false
|
||||
@Published var htmlContent: String?
|
||||
|
||||
var subscriptions = Set<AnyCancellable>()
|
||||
|
||||
func loadContent(dataService: DataService, slug: String) {
|
||||
isLoading = true
|
||||
|
||||
guard let viewer = dataService.currentViewer else { return }
|
||||
|
||||
dataService.articleContentPublisher(username: viewer.username, slug: slug).sink(
|
||||
receiveCompletion: { [weak self] completion in
|
||||
guard case .failure = completion else { return }
|
||||
self?.isLoading = false
|
||||
},
|
||||
receiveValue: { [weak self] htmlContent in
|
||||
self?.htmlContent = htmlContent
|
||||
}
|
||||
)
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
}
|
||||
|
||||
struct WebReaderContainerView: View {
|
||||
let item: FeedItem
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@StateObject var viewModel = WebReaderViewModel()
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let htmlContent = viewModel.htmlContent {
|
||||
WebReader(htmlContent: htmlContent, item: item)
|
||||
} else {
|
||||
Color.clear
|
||||
.contentShape(Rectangle())
|
||||
.onAppear {
|
||||
if !viewModel.isLoading {
|
||||
viewModel.loadContent(dataService: dataService, slug: item.slug)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct WebReader: UIViewRepresentable {
|
||||
let htmlContent: String
|
||||
let item: FeedItem
|
||||
|
||||
func makeUIView(context _: Context) -> WKWebView {
|
||||
print(WebReaderResources.bundleURL)
|
||||
let webView = WKWebView()
|
||||
webView.loadHTMLString(
|
||||
WebReaderContent(htmlContent: htmlContent, item: item).styledContent,
|
||||
baseURL: WebReaderResources.bundleURL
|
||||
)
|
||||
// webView.configuration.userContentController.addUserScript(WebReaderResources.cssScript)
|
||||
|
||||
return webView
|
||||
}
|
||||
|
||||
func updateUIView(_: WKWebView, context _: UIViewRepresentableContext<WebReader>) {}
|
||||
}
|
||||
@ -0,0 +1,75 @@
|
||||
import Foundation
|
||||
import Models
|
||||
import Utils
|
||||
|
||||
struct WebReaderContent {
|
||||
let textFontSize: String
|
||||
let fontColor: String
|
||||
let fontColorTransparent: String
|
||||
let tableHeaderColor: String
|
||||
let headerColor: String
|
||||
let margin: String
|
||||
let content: String
|
||||
let item: FeedItem
|
||||
|
||||
init(
|
||||
htmlContent: String,
|
||||
item: FeedItem,
|
||||
isDark: Bool = false,
|
||||
fontSize: String = "16px",
|
||||
margin: String = "24px"
|
||||
) {
|
||||
self.textFontSize = fontSize
|
||||
self.fontColor = isDark ? "#B9B9B9" : "#3D3D3D"
|
||||
self.fontColorTransparent = isDark ? "rgba(185,185,185,0.65)" : "rgba(185,185,185,0.65)"
|
||||
self.tableHeaderColor = "#FFFFFF"
|
||||
self.headerColor = isDark ? "#B9B9B9" : "#3D3D3D"
|
||||
self.margin = margin
|
||||
self.content = htmlContent
|
||||
self.item = item
|
||||
}
|
||||
|
||||
var styleString: String {
|
||||
// swiftlint:disable line_length
|
||||
"--text-font-size:\(textFontSize);--font-color:\(fontColor);--font-color-transparent\(fontColorTransparent);--table-header-color:\(tableHeaderColor);--headers-color:\(headerColor);--app-margin:\(margin);"
|
||||
}
|
||||
|
||||
var styledContent: String {
|
||||
"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no' />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root" style="\(styleString)">
|
||||
<div>React App\(WebReaderResources.bundleURL)</div>
|
||||
<script type="text/javascript">
|
||||
function loadArticle() {
|
||||
window.omnivoreArticle = {
|
||||
id: "test",
|
||||
linkId: "test",
|
||||
slug: "test-slug",
|
||||
createdAt: new Date().toISOString(),
|
||||
savedAt: new Date().toISOString(),
|
||||
url: "https://example.com",
|
||||
title: `\(item.title)`,
|
||||
content: `\(content)`,
|
||||
originalArticleUrl: "https://example.com",
|
||||
contentReader: "WEB",
|
||||
readingProgressPercent: \(item.readingProgress),
|
||||
readingProgressAnchorIndex: \(item.readingProgressAnchor),
|
||||
highlights: [],
|
||||
}
|
||||
}
|
||||
|
||||
loadArticle()
|
||||
</script>
|
||||
</div>
|
||||
<script src="bundle.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,55 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
public extension DataService {
|
||||
func articleContentPublisher(username: String, slug: String) -> AnyPublisher<String, ServerError> {
|
||||
enum QueryResult {
|
||||
case success(result: String)
|
||||
case error(error: String)
|
||||
}
|
||||
|
||||
let articleSelection = Selection.Article {
|
||||
try $0.content()
|
||||
}
|
||||
|
||||
let selection = Selection<QueryResult, Unions.ArticleResult> {
|
||||
try $0.on(
|
||||
articleSuccess: .init {
|
||||
QueryResult.success(result: try $0.article(selection: articleSelection))
|
||||
},
|
||||
articleError: .init {
|
||||
QueryResult.error(error: try $0.errorCodes().description)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
let query = Selection.Query {
|
||||
try $0.article(username: username, slug: slug, selection: selection)
|
||||
}
|
||||
|
||||
let path = appEnvironment.graphqlPath
|
||||
let headers = networker.defaultHeaders
|
||||
|
||||
return Deferred {
|
||||
Future { promise in
|
||||
send(query, to: path, headers: headers) { result in
|
||||
switch result {
|
||||
case let .success(payload):
|
||||
switch payload.data {
|
||||
case let .success(result: result):
|
||||
promise(.success(result))
|
||||
case .error:
|
||||
promise(.failure(.unknown))
|
||||
}
|
||||
case .failure:
|
||||
promise(.failure(.unknown))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.receive(on: DispatchQueue.main)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
@ -15,4 +15,5 @@ public enum FeatureFlag {
|
||||
public static let enableShareButton = false
|
||||
public static let enableSnooze = false
|
||||
public static let showFeedItemTags = false
|
||||
public static let useLocalWebView = true
|
||||
}
|
||||
|
||||
2
apple/OmnivoreKit/Sources/Utils/Resources/bundle.js
Normal file
2
apple/OmnivoreKit/Sources/Utils/Resources/bundle.js
Normal file
File diff suppressed because one or more lines are too long
355
apple/OmnivoreKit/Sources/Utils/Resources/reader.css
Normal file
355
apple/OmnivoreKit/Sources/Utils/Resources/reader.css
Normal file
@ -0,0 +1,355 @@
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--text-font-family: inter;
|
||||
--line-height: 150%;
|
||||
--blockquote-padding: 0.5em 1em;
|
||||
--blockquote-icon-font-size: 1.3rem;
|
||||
--figure-margin: 1.6rem auto;
|
||||
--hr-margin: 1em;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 576px) {
|
||||
:root {
|
||||
--blockquote-padding: 1em 2em;
|
||||
--blockquote-icon-font-size: 1.7rem;
|
||||
--figure-margin: 2.6875rem auto;
|
||||
--hr-margin: 2em;
|
||||
margin: 30px calc(var(--app-margin) / 2);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
:root {
|
||||
max-width: 92%;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 992px) {
|
||||
:root {
|
||||
margin: 30px 0;
|
||||
width: auto;
|
||||
max-width: calc(1024px - var(--app-margin));
|
||||
}
|
||||
}
|
||||
|
||||
.page * {
|
||||
padding: 16px;
|
||||
max-width: 94%;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
color: var(--colors-highlightText);
|
||||
background-color: var(--colors-highlightBackground);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.highlight_with_note {
|
||||
color: var(--colors-highlightText);
|
||||
border-bottom: 2px var(--colors-highlightBackground) solid;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.page .highlight_with_note .highlight_note_button {
|
||||
display: unset !important;
|
||||
margin: 0px !important;
|
||||
max-width: unset !important;
|
||||
height: unset !important;
|
||||
padding: 0px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.page h1,
|
||||
.page h2,
|
||||
.page h3,
|
||||
.page h4,
|
||||
.page h5,
|
||||
.page h6 {
|
||||
margin-block-start: 0.83em;
|
||||
margin-block-end: 0.83em;
|
||||
margin-inline-start: 0px;
|
||||
margin-inline-end: 0px;
|
||||
line-height: var(--line-height);
|
||||
font-size: var(--text-font-size);
|
||||
color: var(--headers-color);
|
||||
font-weight: bold;
|
||||
}
|
||||
.page h1 {
|
||||
font-size: 1.5em !important;
|
||||
line-height: 1.4em !important;
|
||||
}
|
||||
.page h2 {
|
||||
font-size: 1.43em !important;
|
||||
}
|
||||
.page h3 {
|
||||
font-size: 1.25em !important;
|
||||
}
|
||||
.page h4,
|
||||
.page h5,
|
||||
.page h6 {
|
||||
font-size: 1em !important;
|
||||
margin: 1em 0 !important;
|
||||
}
|
||||
|
||||
.page .scrollable {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.page div {
|
||||
line-height: var(--line-height);
|
||||
/* font-size: var(--text-font-size); */
|
||||
color: var(--font-color);
|
||||
}
|
||||
|
||||
.page p {
|
||||
font-family: var(--text-font-family);
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
|
||||
color: var(--font-color);
|
||||
|
||||
display: block;
|
||||
margin-block-start: 1em;
|
||||
margin-block-end: 1em;
|
||||
margin-inline-start: 0px;
|
||||
margin-inline-end: 0px;
|
||||
|
||||
> img {
|
||||
display: block;
|
||||
margin: 0.5em auto !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
> iframe {
|
||||
width: 100%;
|
||||
height: 350px;
|
||||
}
|
||||
}
|
||||
|
||||
.page section {
|
||||
line-height: 1.65em;
|
||||
font-size: var(--text-font-size);
|
||||
}
|
||||
|
||||
.page blockquote {
|
||||
display: block;
|
||||
border-left: 1px solid var(--font-color-transparent);
|
||||
padding-left: 16px;
|
||||
font-style: italic;
|
||||
margin-inline-start: 0px;
|
||||
> * {
|
||||
font-style: italic;
|
||||
}
|
||||
p:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.page a {
|
||||
color: var(--font-color-readerFont);
|
||||
}
|
||||
|
||||
.page .highlight a {
|
||||
color: var(--colors-highlightText);
|
||||
}
|
||||
|
||||
.page figure {
|
||||
* {
|
||||
color: var(--font-color-transparent);
|
||||
}
|
||||
|
||||
margin: 30px 0;
|
||||
font-size: 0.75em;
|
||||
line-height: 1.5em;
|
||||
|
||||
figcaption {
|
||||
color: var(--font-color);
|
||||
opacity: 0.7;
|
||||
margin: 10px 20px 10px 0;
|
||||
}
|
||||
|
||||
figcaption * {
|
||||
color: var(--font-color);
|
||||
opacity: 0.7;
|
||||
}
|
||||
figcaption a {
|
||||
color: var(--font-color);
|
||||
/* margin: 10px 20px 10px 0; */
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
> div {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.page figure[aria-label='media'] {
|
||||
margin: var(--figure-margin);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page hr {
|
||||
margin-bottom: var(--hr-margin);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.page table {
|
||||
display: block;
|
||||
word-break: normal;
|
||||
white-space: nowrap;
|
||||
border: 1px solid rgb(216, 216, 216);
|
||||
border-spacing: 0;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8rem;
|
||||
margin: auto;
|
||||
line-height: 1.5em;
|
||||
font-size: 0.9em;
|
||||
max-width: -moz-fit-content;
|
||||
max-width: fit-content;
|
||||
margin: 0 auto;
|
||||
overflow-x: auto;
|
||||
caption {
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
th {
|
||||
border: 1px solid rgb(216, 216, 216);
|
||||
background-color: var(--table-header-color);
|
||||
padding: 5px;
|
||||
}
|
||||
td {
|
||||
border: 1px solid rgb(216, 216, 216);
|
||||
padding: 0.5em;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
margin: 0.5em auto !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
.page ul,
|
||||
.page ol {
|
||||
margin-block-start: 1em;
|
||||
margin-block-end: 1em;
|
||||
margin-inline-start: 0px;
|
||||
margin-inline-end: 0px;
|
||||
padding-inline-start: 40px;
|
||||
margin-top: 18px;
|
||||
|
||||
font-family: var(--text-font-family);
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
|
||||
line-height: var(--line-height);
|
||||
font-size: var(--text-font-size);
|
||||
|
||||
color: var(--font-color);
|
||||
}
|
||||
|
||||
.page li {
|
||||
word-break: break-word;
|
||||
ol,
|
||||
ul {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.page sup,
|
||||
.page sub {
|
||||
position: relative;
|
||||
a {
|
||||
color: inherit;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.page sup {
|
||||
top: -0.3em;
|
||||
}
|
||||
|
||||
.page sub {
|
||||
bottom: 0.3em;
|
||||
}
|
||||
|
||||
.page cite {
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.page .page {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Collapse excess whitespace. */
|
||||
.page p > p:empty,
|
||||
.page div > p:empty,
|
||||
.page p > div:empty,
|
||||
.page div > div:empty,
|
||||
.page p + br,
|
||||
.page p > br:only-child,
|
||||
.page div > br:only-child,
|
||||
.page img + br {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.page video {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.page dl {
|
||||
display: block;
|
||||
margin-block-start: 1em;
|
||||
margin-block-end: 1em;
|
||||
margin-inline-start: 0px;
|
||||
margin-inline-end: 0px;
|
||||
}
|
||||
|
||||
.page dd {
|
||||
display: block;
|
||||
margin-inline-start: 40px;
|
||||
}
|
||||
|
||||
.page pre,
|
||||
.page code {
|
||||
vertical-align: bottom;
|
||||
word-wrap: initial;
|
||||
font-family: 'SF Mono', monospace !important;
|
||||
white-space: pre;
|
||||
direction: ltr;
|
||||
unicode-bidi: embed;
|
||||
color: var(--font-color);
|
||||
max-width: -moz-fit-content;
|
||||
margin: 0;
|
||||
overflow-x: auto;
|
||||
word-wrap: normal;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.page img {
|
||||
display: block;
|
||||
margin: 0.5em auto !important;
|
||||
max-width: 100% !important;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.page .page {
|
||||
text-align: start;
|
||||
word-wrap: break-word;
|
||||
|
||||
font-size: var(--text-font-size);
|
||||
line-height: var(--line-height);
|
||||
}
|
||||
|
||||
.page .omnivore-instagram-embed {
|
||||
img {
|
||||
margin: 0 !important;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
30
apple/OmnivoreKit/Sources/Utils/WebReaderResources.swift
Normal file
30
apple/OmnivoreKit/Sources/Utils/WebReaderResources.swift
Normal file
@ -0,0 +1,30 @@
|
||||
import Foundation
|
||||
import WebKit
|
||||
|
||||
public enum WebReaderResources {
|
||||
public static var cssScript: WKUserScript {
|
||||
WKUserScript(source: css(), injectionTime: .atDocumentEnd, forMainFrameOnly: false)
|
||||
}
|
||||
|
||||
public static var bundleURL: URL {
|
||||
Bundle.module.bundleURL
|
||||
}
|
||||
}
|
||||
|
||||
private func css() -> String {
|
||||
guard let path = Bundle.module.path(forResource: "reader", ofType: "css") else { return "" }
|
||||
let cssString = (try? String(contentsOfFile: path, encoding: .utf8)) ?? ""
|
||||
return """
|
||||
javascript:(function() {
|
||||
var parent = document.getElementsByTagName('head').item(0);
|
||||
var style = document.createElement('style');
|
||||
style.type = 'text/css';
|
||||
style.innerHTML = window.atob('\(encodeStringTo64(fromString: cssString))');
|
||||
parent.appendChild(style)})()
|
||||
"""
|
||||
}
|
||||
|
||||
private func encodeStringTo64(fromString: String) -> String {
|
||||
let plainData = fromString.data(using: .utf8)
|
||||
return plainData?.base64EncodedString(options: []) ?? ""
|
||||
}
|
||||
Reference in New Issue
Block a user