Markdown support in recipe ingredients, instructions and tools
This commit is contained in:
@@ -93,21 +93,59 @@ class ObservableRecipeDetail: ObservableObject {
|
||||
)
|
||||
}
|
||||
|
||||
static func adjustIngredient(_ ingredient: String, by factor: Double) -> AttributedString {
|
||||
static func applyMarkdownStyling(_ text: String) -> AttributedString {
|
||||
if var markdownString = try? AttributedString(
|
||||
markdown: text,
|
||||
options: .init(
|
||||
allowsExtendedAttributes: true,
|
||||
interpretedSyntax: .full,
|
||||
failurePolicy: .returnPartiallyParsedIfPossible
|
||||
)
|
||||
) {
|
||||
for (intentBlock, intentRange) in markdownString.runs[AttributeScopes.FoundationAttributes.PresentationIntentAttribute.self].reversed() {
|
||||
guard let intentBlock = intentBlock else { continue }
|
||||
for intent in intentBlock.components {
|
||||
switch intent.kind {
|
||||
case .header(level: let level):
|
||||
switch level {
|
||||
case 1:
|
||||
markdownString[intentRange].font = .system(.title).bold()
|
||||
case 2:
|
||||
markdownString[intentRange].font = .system(.title2).bold()
|
||||
case 3:
|
||||
markdownString[intentRange].font = .system(.title3).bold()
|
||||
default:
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if intentRange.lowerBound != markdownString.startIndex {
|
||||
markdownString.characters.insert(contentsOf: "\n", at: intentRange.lowerBound)
|
||||
}
|
||||
}
|
||||
return markdownString
|
||||
}
|
||||
return AttributedString(text)
|
||||
}
|
||||
|
||||
static func adjustIngredient(_ ingredient: AttributedString, by factor: Double) -> AttributedString {
|
||||
if factor == 0 {
|
||||
return AttributedString(ingredient)
|
||||
return ingredient
|
||||
}
|
||||
// Match mixed fractions first
|
||||
var matches = ObservableRecipeDetail.matchPatternAndMultiply(
|
||||
.mixedFraction,
|
||||
in: ingredient,
|
||||
in: String(ingredient.characters),
|
||||
multFactor: factor
|
||||
)
|
||||
// Then match fractions, exclude mixed fraction ranges
|
||||
matches.append(contentsOf:
|
||||
ObservableRecipeDetail.matchPatternAndMultiply(
|
||||
.fraction,
|
||||
in: ingredient,
|
||||
in: String(ingredient.characters),
|
||||
multFactor: factor,
|
||||
excludedRanges: matches.map({ tuple in tuple.1 })
|
||||
)
|
||||
@@ -116,7 +154,7 @@ class ObservableRecipeDetail: ObservableObject {
|
||||
matches.append(contentsOf:
|
||||
ObservableRecipeDetail.matchPatternAndMultiply(
|
||||
.number,
|
||||
in: ingredient,
|
||||
in: String(ingredient.characters),
|
||||
multFactor: factor,
|
||||
excludedRanges: matches.map({ tuple in tuple.1 })
|
||||
)
|
||||
@@ -124,7 +162,8 @@ class ObservableRecipeDetail: ObservableObject {
|
||||
// Sort matches by match range lower bound, descending.
|
||||
matches.sort(by: { a, b in a.1.lowerBound > b.1.lowerBound})
|
||||
|
||||
var attributedString = AttributedString(ingredient)
|
||||
var attributedString = ingredient
|
||||
|
||||
for (newSubstring, matchRange) in matches {
|
||||
guard let range = Range(matchRange, in: attributedString) else { continue }
|
||||
var attributedSubString = AttributedString(newSubstring)
|
||||
|
||||
@@ -109,7 +109,7 @@ struct RecipeDetail: Codable {
|
||||
totalTime = try container.decodeIfPresent(String.self, forKey: .totalTime)
|
||||
description = try container.decode(String.self, forKey: .description)
|
||||
url = try container.decode(String.self, forKey: .url)
|
||||
recipeYield = try container.decode(Int.self, forKey: .recipeYield)
|
||||
recipeYield = try container.decode(Int?.self, forKey: .recipeYield) ?? 1
|
||||
recipeCategory = try container.decode(String.self, forKey: .recipeCategory)
|
||||
tool = try container.decode([String].self, forKey: .tool)
|
||||
recipeIngredient = try container.decode([String].self, forKey: .recipeIngredient)
|
||||
|
||||
@@ -19,7 +19,7 @@ struct RecipeListSection: View {
|
||||
ForEach(list, id: \.self) { item in
|
||||
HStack(alignment: .top) {
|
||||
Text("\u{2022}")
|
||||
Text("\(item)")
|
||||
Text(ObservableRecipeDetail.applyMarkdownStyling(item))
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
.padding(4)
|
||||
|
||||
@@ -129,7 +129,7 @@ fileprivate struct IngredientListItem: View {
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
if unmodified {
|
||||
Text(ingredient)
|
||||
Text(ObservableRecipeDetail.applyMarkdownStyling(ingredient))
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineLimit(5)
|
||||
} else {
|
||||
@@ -142,9 +142,9 @@ fileprivate struct IngredientListItem: View {
|
||||
}
|
||||
.onChange(of: servings) { newServings in
|
||||
if recipeYield == 0 {
|
||||
modifiedIngredient = ObservableRecipeDetail.adjustIngredient(ingredient, by: newServings)
|
||||
modifiedIngredient = ObservableRecipeDetail.adjustIngredient(ObservableRecipeDetail.applyMarkdownStyling(ingredient), by: newServings)
|
||||
} else {
|
||||
modifiedIngredient = ObservableRecipeDetail.adjustIngredient(ingredient, by: newServings/recipeYield)
|
||||
modifiedIngredient = ObservableRecipeDetail.adjustIngredient(ObservableRecipeDetail.applyMarkdownStyling(ingredient), by: newServings/recipeYield)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(isSelected ? Color.secondary : Color.primary)
|
||||
|
||||
@@ -47,7 +47,7 @@ fileprivate struct RecipeInstructionListItem: View {
|
||||
HStack(alignment: .top) {
|
||||
Text("\(index)")
|
||||
.monospaced()
|
||||
Text(instruction)
|
||||
Text(ObservableRecipeDetail.applyMarkdownStyling(instruction))
|
||||
}.padding(4)
|
||||
.foregroundStyle(isSelected ? Color.secondary : Color.primary)
|
||||
.onTapGesture {
|
||||
|
||||
Reference in New Issue
Block a user