Markdown support in recipe ingredients, instructions and tools
This commit is contained in:
Binary file not shown.
@@ -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 {
|
if factor == 0 {
|
||||||
return AttributedString(ingredient)
|
return ingredient
|
||||||
}
|
}
|
||||||
// Match mixed fractions first
|
// Match mixed fractions first
|
||||||
var matches = ObservableRecipeDetail.matchPatternAndMultiply(
|
var matches = ObservableRecipeDetail.matchPatternAndMultiply(
|
||||||
.mixedFraction,
|
.mixedFraction,
|
||||||
in: ingredient,
|
in: String(ingredient.characters),
|
||||||
multFactor: factor
|
multFactor: factor
|
||||||
)
|
)
|
||||||
// Then match fractions, exclude mixed fraction ranges
|
// Then match fractions, exclude mixed fraction ranges
|
||||||
matches.append(contentsOf:
|
matches.append(contentsOf:
|
||||||
ObservableRecipeDetail.matchPatternAndMultiply(
|
ObservableRecipeDetail.matchPatternAndMultiply(
|
||||||
.fraction,
|
.fraction,
|
||||||
in: ingredient,
|
in: String(ingredient.characters),
|
||||||
multFactor: factor,
|
multFactor: factor,
|
||||||
excludedRanges: matches.map({ tuple in tuple.1 })
|
excludedRanges: matches.map({ tuple in tuple.1 })
|
||||||
)
|
)
|
||||||
@@ -116,7 +154,7 @@ class ObservableRecipeDetail: ObservableObject {
|
|||||||
matches.append(contentsOf:
|
matches.append(contentsOf:
|
||||||
ObservableRecipeDetail.matchPatternAndMultiply(
|
ObservableRecipeDetail.matchPatternAndMultiply(
|
||||||
.number,
|
.number,
|
||||||
in: ingredient,
|
in: String(ingredient.characters),
|
||||||
multFactor: factor,
|
multFactor: factor,
|
||||||
excludedRanges: matches.map({ tuple in tuple.1 })
|
excludedRanges: matches.map({ tuple in tuple.1 })
|
||||||
)
|
)
|
||||||
@@ -124,7 +162,8 @@ class ObservableRecipeDetail: ObservableObject {
|
|||||||
// Sort matches by match range lower bound, descending.
|
// Sort matches by match range lower bound, descending.
|
||||||
matches.sort(by: { a, b in a.1.lowerBound > b.1.lowerBound})
|
matches.sort(by: { a, b in a.1.lowerBound > b.1.lowerBound})
|
||||||
|
|
||||||
var attributedString = AttributedString(ingredient)
|
var attributedString = ingredient
|
||||||
|
|
||||||
for (newSubstring, matchRange) in matches {
|
for (newSubstring, matchRange) in matches {
|
||||||
guard let range = Range(matchRange, in: attributedString) else { continue }
|
guard let range = Range(matchRange, in: attributedString) else { continue }
|
||||||
var attributedSubString = AttributedString(newSubstring)
|
var attributedSubString = AttributedString(newSubstring)
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ struct RecipeDetail: Codable {
|
|||||||
totalTime = try container.decodeIfPresent(String.self, forKey: .totalTime)
|
totalTime = try container.decodeIfPresent(String.self, forKey: .totalTime)
|
||||||
description = try container.decode(String.self, forKey: .description)
|
description = try container.decode(String.self, forKey: .description)
|
||||||
url = try container.decode(String.self, forKey: .url)
|
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)
|
recipeCategory = try container.decode(String.self, forKey: .recipeCategory)
|
||||||
tool = try container.decode([String].self, forKey: .tool)
|
tool = try container.decode([String].self, forKey: .tool)
|
||||||
recipeIngredient = try container.decode([String].self, forKey: .recipeIngredient)
|
recipeIngredient = try container.decode([String].self, forKey: .recipeIngredient)
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ struct RecipeListSection: View {
|
|||||||
ForEach(list, id: \.self) { item in
|
ForEach(list, id: \.self) { item in
|
||||||
HStack(alignment: .top) {
|
HStack(alignment: .top) {
|
||||||
Text("\u{2022}")
|
Text("\u{2022}")
|
||||||
Text("\(item)")
|
Text(ObservableRecipeDetail.applyMarkdownStyling(item))
|
||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.leading)
|
||||||
}
|
}
|
||||||
.padding(4)
|
.padding(4)
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ fileprivate struct IngredientListItem: View {
|
|||||||
.foregroundStyle(.red)
|
.foregroundStyle(.red)
|
||||||
}
|
}
|
||||||
if unmodified {
|
if unmodified {
|
||||||
Text(ingredient)
|
Text(ObservableRecipeDetail.applyMarkdownStyling(ingredient))
|
||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.leading)
|
||||||
.lineLimit(5)
|
.lineLimit(5)
|
||||||
} else {
|
} else {
|
||||||
@@ -142,9 +142,9 @@ fileprivate struct IngredientListItem: View {
|
|||||||
}
|
}
|
||||||
.onChange(of: servings) { newServings in
|
.onChange(of: servings) { newServings in
|
||||||
if recipeYield == 0 {
|
if recipeYield == 0 {
|
||||||
modifiedIngredient = ObservableRecipeDetail.adjustIngredient(ingredient, by: newServings)
|
modifiedIngredient = ObservableRecipeDetail.adjustIngredient(ObservableRecipeDetail.applyMarkdownStyling(ingredient), by: newServings)
|
||||||
} else {
|
} else {
|
||||||
modifiedIngredient = ObservableRecipeDetail.adjustIngredient(ingredient, by: newServings/recipeYield)
|
modifiedIngredient = ObservableRecipeDetail.adjustIngredient(ObservableRecipeDetail.applyMarkdownStyling(ingredient), by: newServings/recipeYield)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.foregroundStyle(isSelected ? Color.secondary : Color.primary)
|
.foregroundStyle(isSelected ? Color.secondary : Color.primary)
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ fileprivate struct RecipeInstructionListItem: View {
|
|||||||
HStack(alignment: .top) {
|
HStack(alignment: .top) {
|
||||||
Text("\(index)")
|
Text("\(index)")
|
||||||
.monospaced()
|
.monospaced()
|
||||||
Text(instruction)
|
Text(ObservableRecipeDetail.applyMarkdownStyling(instruction))
|
||||||
}.padding(4)
|
}.padding(4)
|
||||||
.foregroundStyle(isSelected ? Color.secondary : Color.primary)
|
.foregroundStyle(isSelected ? Color.secondary : Color.primary)
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
|
|||||||
Reference in New Issue
Block a user