Implement image comparison command
This commit is contained in:
@@ -12,6 +12,8 @@ extension HeaderElement {
|
||||
static let defaultCssFileOrder = 42
|
||||
|
||||
static let audioPlayerCssOrder = 43
|
||||
|
||||
static let imageCompareCssOrder = 44
|
||||
}
|
||||
|
||||
enum HeaderElement {
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
struct ImageSet {
|
||||
struct ImageSet: HtmlProducer {
|
||||
|
||||
let image: FileResource
|
||||
|
||||
@@ -12,12 +12,15 @@ struct ImageSet {
|
||||
|
||||
let description: String
|
||||
|
||||
init(image: FileResource, maxWidth: Int, maxHeight: Int, description: String, quality: CGFloat = 0.7) {
|
||||
let extraAttributes: String
|
||||
|
||||
init(image: FileResource, maxWidth: Int, maxHeight: Int, description: String, quality: CGFloat = 0.7, extraAttributes: String? = nil) {
|
||||
self.image = image
|
||||
self.maxWidth = maxWidth
|
||||
self.maxHeight = maxHeight
|
||||
self.description = description
|
||||
self.quality = quality
|
||||
self.extraAttributes = extraAttributes ?? ""
|
||||
}
|
||||
|
||||
var jobs: [ImageVersion] {
|
||||
@@ -36,17 +39,16 @@ struct ImageSet {
|
||||
]
|
||||
}
|
||||
|
||||
var content: String {
|
||||
func populate(_ result: inout String) {
|
||||
let fileExtension = image.type.fileExtension.map { "." + $0 } ?? ""
|
||||
|
||||
let prefix1x = "/\(image.outputImageFolder)/\(maxWidth)x\(maxHeight)"
|
||||
let prefix2x = "/\(image.outputImageFolder)/\(maxWidth*2)x\(maxHeight*2)"
|
||||
|
||||
var result = "<picture>"
|
||||
result += "<picture>"
|
||||
result += "<source type='image/avif' srcset='\(prefix1x).avif 1x, \(prefix2x).avif 2x'/>"
|
||||
result += "<source type='image/webp' srcset='\(prefix1x).webp 1x, \(prefix1x).webp 2x'/>"
|
||||
result += "<img srcset='\(prefix2x)\(fileExtension) 2x' src='\(prefix1x)\(fileExtension)' loading='lazy' alt='\(description.htmlEscaped())'/>"
|
||||
result += "<img srcset='\(prefix2x)\(fileExtension) 2x' src='\(prefix1x)\(fileExtension)' loading='lazy' alt='\(description.htmlEscaped())'\(extraAttributes)/>"
|
||||
result += "</picture>"
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
@@ -11,6 +11,10 @@ enum KnownHeaderElement: Int {
|
||||
/// JavaScript file for the audio player
|
||||
case audioPlayerJs = 2
|
||||
|
||||
case imageCompareJs = 5
|
||||
|
||||
case imageCompareCss = 6
|
||||
|
||||
func header(content: Content) -> HeaderElement? {
|
||||
switch self {
|
||||
case .codeHightlighting:
|
||||
@@ -29,6 +33,14 @@ enum KnownHeaderElement: Int {
|
||||
if let file = content.settings.pages.audioPlayerJsFile {
|
||||
return .js(file: file, defer: true)
|
||||
}
|
||||
case .imageCompareJs:
|
||||
if let file = content.settings.pages.imageCompareJsFile {
|
||||
return .js(file: file, defer: true)
|
||||
}
|
||||
case .imageCompareCss:
|
||||
if let file = content.settings.pages.imageCompareCssFile {
|
||||
return .css(file: file, order: HeaderElement.imageCompareCssOrder)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -53,6 +65,10 @@ extension KnownHeaderElement: CustomStringConvertible {
|
||||
return "audio-player-css"
|
||||
case .audioPlayerJs:
|
||||
return "audio-player-js"
|
||||
case .imageCompareJs:
|
||||
return "image-compare-js"
|
||||
case .imageCompareCss:
|
||||
return "image-compare-css"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -8,7 +8,7 @@ struct AudioPlayerCommandProcessor: CommandProcessor {
|
||||
|
||||
let results: PageGenerationResults
|
||||
|
||||
init(content: Content, results: PageGenerationResults) {
|
||||
init(content: Content, results: PageGenerationResults, language: ContentLanguage) {
|
||||
self.content = content
|
||||
self.results = results
|
||||
}
|
||||
|
@@ -5,7 +5,7 @@ struct BoxCommandProcessor: CommandProcessor {
|
||||
|
||||
let results: PageGenerationResults
|
||||
|
||||
init(content: Content, results: PageGenerationResults) {
|
||||
init(content: Content, results: PageGenerationResults, language: ContentLanguage) {
|
||||
self.results = results
|
||||
}
|
||||
|
||||
|
@@ -7,7 +7,7 @@ struct ButtonCommandProcessor: CommandProcessor {
|
||||
|
||||
let results: PageGenerationResults
|
||||
|
||||
init(content: Content, results: PageGenerationResults) {
|
||||
init(content: Content, results: PageGenerationResults, language: ContentLanguage) {
|
||||
self.content = content
|
||||
self.results = results
|
||||
}
|
||||
|
@@ -3,7 +3,7 @@ protocol CommandProcessor {
|
||||
|
||||
var commandType: ShorthandMarkdownKey { get }
|
||||
|
||||
init(content: Content, results: PageGenerationResults)
|
||||
init(content: Content, results: PageGenerationResults, language: ContentLanguage)
|
||||
|
||||
func process(_ arguments: [String], markdown: Substring) -> String
|
||||
}
|
||||
|
@@ -5,7 +5,7 @@ struct IconCommandProcessor: CommandProcessor {
|
||||
|
||||
let results: PageGenerationResults
|
||||
|
||||
init(content: Content, results: PageGenerationResults) {
|
||||
init(content: Content, results: PageGenerationResults, language: ContentLanguage) {
|
||||
self.results = results
|
||||
}
|
||||
|
||||
|
@@ -0,0 +1,53 @@
|
||||
|
||||
struct ImageCompareCommandProcessor: CommandProcessor {
|
||||
|
||||
let commandType: ShorthandMarkdownKey = .imageCompare
|
||||
|
||||
let content: Content
|
||||
|
||||
let results: PageGenerationResults
|
||||
|
||||
let language: ContentLanguage
|
||||
|
||||
init(content: Content, results: PageGenerationResults, language: ContentLanguage) {
|
||||
self.content = content
|
||||
self.results = results
|
||||
self.language = language
|
||||
}
|
||||
|
||||
func process(_ arguments: [String], markdown: Substring) -> String {
|
||||
guard arguments.count == 2 else {
|
||||
results.invalid(command: .imageCompare, markdown)
|
||||
return ""
|
||||
}
|
||||
let leftImageId = arguments[0]
|
||||
let rightImageId = arguments[1]
|
||||
guard let leftImage = content.image(leftImageId) else {
|
||||
results.missing(file: leftImageId, source: "Image compare")
|
||||
return ""
|
||||
}
|
||||
guard let rightImage = content.image(rightImageId) else {
|
||||
results.missing(file: rightImageId, source: "Image compare")
|
||||
return ""
|
||||
}
|
||||
|
||||
let size = content.settings.pages.contentWidth
|
||||
|
||||
let leftImageSet = leftImage.imageSet(
|
||||
width: size, height: size,
|
||||
language: language,
|
||||
extraAttributes: ImageCompare.extraAttributes)
|
||||
|
||||
let rightImageSet = rightImage.imageSet(
|
||||
width: size, height: size,
|
||||
language: language,
|
||||
extraAttributes: ImageCompare.extraAttributes)
|
||||
|
||||
results.require(imageSet: leftImageSet)
|
||||
results.require(imageSet: rightImageSet)
|
||||
results.require(icon: ImageCompare.requiredIcon)
|
||||
results.require(headers: .imageCompareJs, .imageCompareCss)
|
||||
|
||||
return ImageCompare(left: leftImageSet, right: rightImageSet).content
|
||||
}
|
||||
}
|
@@ -7,7 +7,7 @@ struct LabelsCommandProcessor: CommandProcessor {
|
||||
|
||||
let results: PageGenerationResults
|
||||
|
||||
init(content: Content, results: PageGenerationResults) {
|
||||
init(content: Content, results: PageGenerationResults, language: ContentLanguage) {
|
||||
self.content = content
|
||||
self.results = results
|
||||
}
|
||||
|
@@ -11,7 +11,7 @@ struct PageHtmlProcessor: CommandProcessor {
|
||||
|
||||
let content: Content
|
||||
|
||||
init(content: Content, results: PageGenerationResults) {
|
||||
init(content: Content, results: PageGenerationResults, language: ContentLanguage) {
|
||||
self.content = content
|
||||
self.results = results
|
||||
}
|
||||
|
@@ -7,7 +7,7 @@ struct VideoCommandProcessor: CommandProcessor {
|
||||
|
||||
let results: PageGenerationResults
|
||||
|
||||
init(content: Content, results: PageGenerationResults) {
|
||||
init(content: Content, results: PageGenerationResults, language: ContentLanguage) {
|
||||
self.content = content
|
||||
self.results = results
|
||||
}
|
||||
|
@@ -25,6 +25,8 @@ final class PageContentParser {
|
||||
|
||||
private let video: VideoCommandProcessor
|
||||
|
||||
private let imageCompare: ImageCompareCommandProcessor
|
||||
|
||||
// MARK: Other handlers
|
||||
|
||||
private let inlineLink: InlineLinkProcessor
|
||||
@@ -43,13 +45,14 @@ final class PageContentParser {
|
||||
self.content = content
|
||||
self.results = results
|
||||
self.language = language
|
||||
self.buttonHandler = .init(content: content, results: results)
|
||||
self.labelHandler = .init(content: content, results: results)
|
||||
self.audioPlayer = .init(content: content, results: results)
|
||||
self.icons = .init(content: content, results: results)
|
||||
self.box = .init(content: content, results: results)
|
||||
self.html = .init(content: content, results: results)
|
||||
self.video = .init(content: content, results: results)
|
||||
self.buttonHandler = .init(content: content, results: results, language: language)
|
||||
self.labelHandler = .init(content: content, results: results, language: language)
|
||||
self.audioPlayer = .init(content: content, results: results, language: language)
|
||||
self.icons = .init(content: content, results: results, language: language)
|
||||
self.box = .init(content: content, results: results, language: language)
|
||||
self.html = .init(content: content, results: results, language: language)
|
||||
self.video = .init(content: content, results: results, language: language)
|
||||
self.imageCompare = .init(content: content, results: results, language: language)
|
||||
|
||||
self.inlineLink = .init(content: content, results: results, language: language)
|
||||
self.code = .init(results: results)
|
||||
@@ -136,6 +139,8 @@ final class PageContentParser {
|
||||
return handleTagLink(arguments, markdown: markdown)
|
||||
case .icons:
|
||||
return icons.process(arguments, markdown: markdown)
|
||||
case .imageCompare:
|
||||
return imageCompare.process(arguments, markdown: markdown)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,12 +160,10 @@ final class PageContentParser {
|
||||
}
|
||||
results.used(file: image)
|
||||
|
||||
let caption = arguments.count == 2 ? arguments[1] : nil
|
||||
let altText = image.localized(in: language)
|
||||
|
||||
let path = image.absoluteUrl
|
||||
|
||||
guard !image.type.isSvg else {
|
||||
let path = image.absoluteUrl
|
||||
let altText = image.localized(in: language)
|
||||
return SvgImage(imagePath: path, altText: altText).content
|
||||
}
|
||||
let thumbnail = image.imageSet(width: thumbnailWidth, height: thumbnailWidth, language: language)
|
||||
@@ -169,6 +172,7 @@ final class PageContentParser {
|
||||
let largeImage = image.imageSet(width: largeImageWidth, height: largeImageWidth, language: language)
|
||||
results.require(imageSet: largeImage)
|
||||
|
||||
let caption = arguments.count == 2 ? arguments[1] : nil
|
||||
return PageImage(
|
||||
imageId: imageId.replacingOccurrences(of: ".", with: "-"),
|
||||
thumbnail: thumbnail,
|
||||
|
@@ -53,4 +53,10 @@ enum ShorthandMarkdownKey: String {
|
||||
/// Format: ``
|
||||
case icons
|
||||
|
||||
/**
|
||||
Create an image comparison with a slider.
|
||||
Format: ``
|
||||
*/
|
||||
case imageCompare = "compare"
|
||||
|
||||
}
|
||||
|
@@ -4,7 +4,6 @@ import SFSafeSymbols
|
||||
/**
|
||||
**Content**
|
||||
- Podcast: Fix audio player, preview image
|
||||
- Endeavor Basics: -> image compare command
|
||||
- Article Cap Mosaic: -> GIF feature
|
||||
- iPhone Backgrounds: Add page, html
|
||||
|
||||
@@ -17,7 +16,6 @@ import SFSafeSymbols
|
||||
|
||||
**Features**
|
||||
- GIF Support (No image set, don't rescale)
|
||||
- Image compare command ``
|
||||
- Files: Optional Property `customFilePath` for external files to place them in another location
|
||||
- Files: Property `version` and `sourceUrl` to track asset files
|
||||
- Posts: Generate separate pages for posts to link to
|
||||
|
@@ -164,14 +164,15 @@ final class FileResource: Item {
|
||||
return prefix + "." + ext
|
||||
}
|
||||
|
||||
func imageSet(width: Int, height: Int, language: ContentLanguage, quality: CGFloat = 0.7) -> ImageSet {
|
||||
func imageSet(width: Int, height: Int, language: ContentLanguage, quality: CGFloat = 0.7, extraAttributes: String? = nil) -> ImageSet {
|
||||
let description = self.localized(in: language)
|
||||
return .init(
|
||||
image: self,
|
||||
maxWidth: width,
|
||||
maxHeight: height,
|
||||
description: description,
|
||||
quality: quality)
|
||||
quality: quality,
|
||||
extraAttributes: extraAttributes)
|
||||
}
|
||||
|
||||
func imageVersion(width: Int, height: Int, type: FileType) -> ImageVersion {
|
||||
|
@@ -26,6 +26,12 @@ final class PageSettings: ObservableObject {
|
||||
@Published
|
||||
var modelViewerJsFile: FileResource?
|
||||
|
||||
@Published
|
||||
var imageCompareJsFile: FileResource?
|
||||
|
||||
@Published
|
||||
var imageCompareCssFile: FileResource?
|
||||
|
||||
init(file: PageSettingsFile, files: [String : FileResource]) {
|
||||
self.contentWidth = file.contentWidth
|
||||
self.largeImageWidth = file.largeImageWidth
|
||||
@@ -35,6 +41,7 @@ final class PageSettings: ObservableObject {
|
||||
self.audioPlayerJsFile = file.audioPlayerJsFile.map { files[$0] }
|
||||
self.audioPlayerCssFile = file.audioPlayerCssFile.map { files[$0] }
|
||||
self.modelViewerJsFile = file.modelViewerJsFile.map { files[$0] }
|
||||
self.imageCompareCssFile = file.imageCompareCssFile.map { files[$0] }
|
||||
}
|
||||
|
||||
var file: PageSettingsFile {
|
||||
@@ -45,6 +52,8 @@ final class PageSettings: ObservableObject {
|
||||
codeHighlightingJsFile: codeHighlightingJsFile?.id,
|
||||
audioPlayerJsFile: audioPlayerJsFile?.id,
|
||||
audioPlayerCssFile: audioPlayerCssFile?.id,
|
||||
modelViewerJsFile: modelViewerJsFile?.id)
|
||||
modelViewerJsFile: modelViewerJsFile?.id,
|
||||
imageCompareJsFile: imageCompareJsFile?.id,
|
||||
imageCompareCssFile: imageCompareCssFile?.id)
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,12 @@
|
||||
|
||||
extension Icon {
|
||||
|
||||
struct LeftRightArrow: ContentIcon {
|
||||
|
||||
static let name = "left-right-arrow"
|
||||
|
||||
static let content = """
|
||||
<svg id='\(name)' viewBox='0 0 100 100'><polygon points='10 50 37 80 37 20 10 50' style='fill:currentColor'/><polygon points='90 50 64 20 64 80 90 50' style='fill:currentColor'/></svg>
|
||||
"""
|
||||
}
|
||||
}
|
@@ -53,6 +53,10 @@ enum PageIcon: String, CaseIterable {
|
||||
|
||||
case audioPlayerNext = "next"
|
||||
|
||||
// MARK: Image compare
|
||||
|
||||
case leftRightArrow = "left-right-arrow"
|
||||
|
||||
var icon: ContentIcon.Type {
|
||||
switch self {
|
||||
case .statisticsTime: return StatisticsTimeIcon.self
|
||||
@@ -77,6 +81,7 @@ enum PageIcon: String, CaseIterable {
|
||||
case .location: return Icon.Location.self
|
||||
case .poster: return Icon.Poster.self
|
||||
case .video: return Icon.Video.self
|
||||
case .leftRightArrow:return Icon.LeftRightArrow.self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,20 @@
|
||||
|
||||
struct ImageCompare: HtmlProducer {
|
||||
|
||||
let left: ImageSet
|
||||
|
||||
let right: ImageSet
|
||||
|
||||
static let extraAttributes = " draggable='false''"
|
||||
|
||||
static var requiredIcon: PageIcon { .leftRightArrow }
|
||||
|
||||
func populate(_ result: inout String) {
|
||||
result += "<div class='image-compare' style='aspect-ratio: 748/487'>"
|
||||
result += "<div class='right'>\(left.content)</div>"
|
||||
result += "<div class='left'>\(right.content)</div>"
|
||||
result += "<div class='drag'>"
|
||||
result += "<svg><use href='#\(Icon.LeftRightArrow.name)'></use></svg>"
|
||||
result += "</div></div>" // Close drag, image-compare
|
||||
}
|
||||
}
|
@@ -16,6 +16,10 @@ struct PageSettingsFile {
|
||||
let audioPlayerCssFile: String?
|
||||
|
||||
let modelViewerJsFile: String?
|
||||
|
||||
let imageCompareJsFile: String?
|
||||
|
||||
let imageCompareCssFile: String?
|
||||
}
|
||||
|
||||
extension PageSettingsFile: Codable {
|
||||
@@ -32,6 +36,8 @@ extension PageSettingsFile {
|
||||
codeHighlightingJsFile: nil,
|
||||
audioPlayerJsFile: nil,
|
||||
audioPlayerCssFile: nil,
|
||||
modelViewerJsFile: nil)
|
||||
modelViewerJsFile: nil,
|
||||
imageCompareJsFile: nil,
|
||||
imageCompareCssFile: nil)
|
||||
}
|
||||
}
|
||||
|
@@ -34,31 +34,45 @@ struct PageSettingsDetailView: View {
|
||||
title: "Default CSS File",
|
||||
footer: "The CSS file containing the styling of all pages",
|
||||
selectedFile: $content.settings.pages.defaultCssFile,
|
||||
allowedType: .text)
|
||||
allowedType: .asset)
|
||||
|
||||
FilePropertyView(
|
||||
title: "Code Highlighting File",
|
||||
footer: "The JavaScript file to provide syntax highlighting of code blocks",
|
||||
selectedFile: $content.settings.pages.codeHighlightingJsFile,
|
||||
allowedType: .text)
|
||||
allowedType: .asset)
|
||||
|
||||
FilePropertyView(
|
||||
title: "Audio Player CSS File",
|
||||
footer: "The CSS file to provide the style for the audio player",
|
||||
selectedFile: $content.settings.pages.audioPlayerCssFile,
|
||||
allowedType: .text)
|
||||
allowedType: .asset)
|
||||
|
||||
FilePropertyView(
|
||||
title: "Audio Player JavaScript File",
|
||||
footer: "The CSS file to provide the functionality for the audio player",
|
||||
selectedFile: $content.settings.pages.audioPlayerJsFile,
|
||||
allowedType: .text)
|
||||
allowedType: .asset)
|
||||
|
||||
FilePropertyView(
|
||||
title: "3D Model Viewer File",
|
||||
footer: "The JavaScript file to provide the functionality for the 3D model viewer",
|
||||
selectedFile: $content.settings.pages.modelViewerJsFile,
|
||||
allowedType: .text)
|
||||
allowedType: .asset)
|
||||
|
||||
|
||||
FilePropertyView(
|
||||
title: "Image Comparison CSS File",
|
||||
footer: "The CSS file to provide image comparisons",
|
||||
selectedFile: $content.settings.pages.imageCompareCssFile,
|
||||
allowedType: .asset)
|
||||
|
||||
FilePropertyView(
|
||||
title: "Image Comparison JaveScript File",
|
||||
footer: "The JavaScript file to provide image comparisons",
|
||||
selectedFile: $content.settings.pages.imageCompareJsFile,
|
||||
allowedType: .asset)
|
||||
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
Reference in New Issue
Block a user