1
0
Fork 0
mirror of https://github.com/mealie-recipes/mealie.git synced 2025-07-23 15:19:41 +02:00

Feature/recipe instructions improvements (#785)

* feat(frontend):  split paragraph by 1. 1) or 1: regex matches

* feat(frontend):  Update frontend to support ingredient To step refs

* feat(backend):  Update backend to support ingredient to step refs

* fix title editor

* move about info to site-settings

* update change-log

Co-authored-by: Hayden K <hay-kot@pm.me>
This commit is contained in:
Hayden 2021-11-05 15:48:10 -08:00 committed by GitHub
parent 9f8c61a75a
commit 5cb4a1ade0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 621 additions and 210 deletions

View file

@ -1,6 +1,54 @@
<template>
<section>
<h2 class="mb-4">{{ $t("recipe.instructions") }}</h2>
<section @keyup.ctrl.90="undoMerge">
<!-- Ingredient Link Editor -->
<v-dialog v-model="dialog" width="600">
<v-card>
<v-app-bar dark color="primary" class="mt-n1 mb-3">
<v-icon large left>
{{ $globals.icons.link }}
</v-icon>
<v-toolbar-title class="headline"> Ingredient Linker </v-toolbar-title>
<v-spacer></v-spacer>
</v-app-bar>
<v-card-text class="pt-4">
<p>
{{ activeText }}
</p>
<v-divider class="mb-4"></v-divider>
<v-checkbox
v-for="ing in ingredients"
:key="ing.referenceId"
v-model="activeRefs"
:label="ing.note"
:value="ing.referenceId"
class="mb-n2 mt-n2"
></v-checkbox>
</v-card-text>
<v-divider></v-divider>
<v-card-actions>
<BaseButton cancel @click="dialog = false"> </BaseButton>
<v-spacer></v-spacer>
<BaseButton color="info" @click="autoSetReferences">
<template #icon> {{ $globals.icons.robot }}</template>
Auto
</BaseButton>
<BaseButton save @click="setIngredientIds"> </BaseButton>
</v-card-actions>
</v-card>
</v-dialog>
<div class="d-flex justify-space-between justify-start">
<h2 class="mb-4 mt-1">{{ $t("recipe.instructions") }}</h2>
<BaseButton minor :to="$router.currentRoute.path + '/cook'" cancel color="primary">
<template #icon>
{{ $globals.icons.primary }}
</template>
Cook
</BaseButton>
</div>
<draggable
:disabled="!edit"
:value="value"
@ -35,23 +83,24 @@
@click="toggleDisabled(index)"
>
<v-card-title :class="{ 'pb-0': !isChecked(index) }">
<v-btn
v-if="edit"
fab
x-small
color="white"
class="mr-2"
elevation="0"
@click="removeByIndex(value, index)"
>
<v-btn v-if="edit" fab x-small color="white" class="mr-2" elevation="0" @click="value.splice(index, 1)">
<v-icon size="24" color="error">{{ $globals.icons.delete }}</v-icon>
</v-btn>
{{ $t("recipe.step-index", { step: index + 1 }) }}
<v-btn v-if="edit" text color="primary" class="ml-auto" @click="toggleShowTitle(index)">
{{ !showTitleEditor[index] ? $t("recipe.insert-section") : $t("recipe.remove-section") }}
</v-btn>
<div class="ml-auto">
<BaseOverflowButton
v-if="edit"
small
mode="event"
:items="actionEvents || []"
@merge-above="mergeAbove(index - 1, index)"
@toggle-section="toggleShowTitle(index)"
@link-ingredients="openDialog(index, step.ingredientReferences, step.text)"
>
</BaseOverflowButton>
</div>
<v-icon v-if="edit" class="handle">{{ $globals.icons.arrowUpDown }}</v-icon>
<v-fade-transition>
<v-icon v-show="isChecked(index)" size="24" class="ml-auto" color="success">
@ -62,11 +111,17 @@
<v-card-text v-if="edit">
<v-textarea :key="'instructions' + index" v-model="value[index]['text']" auto-grow dense rows="4">
</v-textarea>
<div v-for="ing in step.ingredientReferences" :key="ing.referenceId">
{{ getIngredientByRefId(ing.referenceId).note }}
</div>
</v-card-text>
<v-expand-transition>
<div v-show="!isChecked(index) && !edit" class="m-0 p-0">
<v-card-text>
<VueMarkdown :source="step.text"> </VueMarkdown>
<div v-for="ing in step.ingredientReferences" :key="ing.referenceId">
{{ getIngredientByRefId(ing.referenceId).note }}
</div>
</v-card-text>
</div>
</v-expand-transition>
@ -77,81 +132,242 @@
</section>
</template>
<script>
<script lang="ts">
import draggable from "vuedraggable";
// @ts-ignore
import VueMarkdown from "@adapttive/vue-markdown";
export default {
import { ref, toRefs, reactive, defineComponent, watch, onMounted } from "@nuxtjs/composition-api";
import { RecipeStep, IngredientToStepRef, RecipeIngredient } from "~/types/api-types/recipe";
interface MergerHistory {
target: number;
source: number;
targetText: string;
sourceText: string;
}
export default defineComponent({
components: {
VueMarkdown,
draggable,
},
props: {
value: {
type: Array,
type: Array as () => RecipeStep[],
required: true,
},
edit: {
type: Boolean,
default: true,
},
ingredients: {
type: Array as () => RecipeIngredient[],
default: () => [],
},
},
data() {
return {
disabledSteps: [],
showTitleEditor: [],
};
},
setup(props, context) {
const state = reactive({
dialog: false,
disabledSteps: [] as number[],
});
watch: {
value: {
handler() {
this.disabledSteps = [];
this.showTitleEditor = this.value.map((x) => this.validateTitle(x.title));
const showTitleEditor = ref<boolean[]>([]);
const actionEvents = [
{
text: "Toggle Section",
event: "toggle-section",
},
},
},
{
text: "Link Ingredients",
event: "link-ingredients",
},
{
text: "Merge Above",
event: "merge-above",
},
];
mounted() {
this.showTitleEditor = this.value.map((x) => this.validateTitle(x.title));
},
methods: {
removeByIndex(list, index) {
list.splice(index, 1);
},
validateTitle(title) {
// ===============================================================
// UI State Helpers
function validateTitle(title: string | undefined) {
return !(title === null || title === "");
},
toggleDisabled(stepIndex) {
if (this.edit) return;
if (this.disabledSteps.includes(stepIndex)) {
const index = this.disabledSteps.indexOf(stepIndex);
}
watch(props.value, (v) => {
state.disabledSteps = [];
showTitleEditor.value = v.map((x) => validateTitle(x.title));
});
// Eliminate state with an eager call to watcher?
onMounted(() => {
showTitleEditor.value = props.value.map((x) => validateTitle(x.title));
});
function toggleDisabled(stepIndex: number) {
if (props.edit) {
return;
}
if (state.disabledSteps.includes(stepIndex)) {
const index = state.disabledSteps.indexOf(stepIndex);
if (index !== -1) {
this.disabledSteps.splice(index, 1);
state.disabledSteps.splice(index, 1);
}
} else {
this.disabledSteps.push(stepIndex);
state.disabledSteps.push(stepIndex);
}
},
isChecked(stepIndex) {
if (this.disabledSteps.includes(stepIndex) && !this.edit) {
}
function isChecked(stepIndex: number) {
if (state.disabledSteps.includes(stepIndex) && !props.edit) {
return "disabled-card";
}
},
toggleShowTitle(index) {
const newVal = !this.showTitleEditor[index];
}
function toggleShowTitle(index: number) {
const newVal = !showTitleEditor.value[index];
if (!newVal) {
this.value[index].title = "";
props.value[index].title = "";
}
this.$set(this.showTitleEditor, index, newVal);
},
updateIndex(data) {
this.$emit("input", data);
},
// Must create a new temporary list due to vue-composition-api backport limitations (I think...)
const tempList = [...showTitleEditor.value];
tempList[index] = newVal;
showTitleEditor.value = tempList;
}
function updateIndex(data: RecipeStep) {
context.emit("input", data);
}
// ===============================================================
// Ingredient Linker
const activeRefs = ref<String[]>([]);
const activeIndex = ref(0);
const activeText = ref("");
function openDialog(idx: number, refs: IngredientToStepRef[], text: string) {
activeText.value = text;
activeIndex.value = idx;
state.dialog = true;
activeRefs.value = refs.map((ref) => ref.referenceId);
}
function setIngredientIds() {
const instruction = props.value[activeIndex.value];
instruction.ingredientReferences = activeRefs.value.map((ref) => {
return {
referenceId: ref as string,
};
});
state.dialog = false;
}
function autoSetReferences() {
// Ingore matching blacklisted words when auto-linking - This is kind of a cludgey implementation. We're blacklisting common words but
// other common phrases trigger false positives and I'm not sure how else to approach this. In the future I maybe look at looking directly
// at the food variable and seeing if the food is in the instructions, but I still need to support those who don't want to provide the value
// and only use the "notes" feature.
const blackListedText = [
"and",
"or",
"the",
"a",
"an",
"of",
"in",
"on",
"to",
"for",
"by",
"with",
"without",
"",
" ",
];
const blackListedRegexMatch = /\d/gm; // Match Any Number
// Check if any of the words in the active text match the ingredient text
const instructionsByWord = activeText.value.toLowerCase().split(" ");
instructionsByWord.forEach((word) => {
if (blackListedText.includes(word) || word.match(blackListedRegexMatch)) {
return;
}
props.ingredients.forEach((ingredient) => {
if (
ingredient.note.toLowerCase().includes(" " + word) &&
!activeRefs.value.includes(ingredient.referenceId)
) {
console.info("Word Matched", `'${word}'`, ingredient.note);
activeRefs.value.push(ingredient.referenceId);
}
});
});
}
function getIngredientByRefId(refId: String) {
return props.ingredients.find((ing) => ing.referenceId === refId) || "";
}
// ===============================================================
// Instruction Merger
const mergeHistory = ref<MergerHistory[]>([]);
function mergeAbove(target: number, source: number) {
if (target < 0) {
return;
}
mergeHistory.value.push({
target,
source,
targetText: props.value[target].text,
sourceText: props.value[source].text,
});
props.value[target].text += " " + props.value[source].text;
props.value.splice(source, 1);
}
function undoMerge(event: KeyboardEvent) {
if (event.ctrlKey && event.code === "KeyZ") {
if (!(mergeHistory.value?.length > 0)) {
return;
}
const lastMerge = mergeHistory.value.pop();
if (!lastMerge) {
return;
}
props.value[lastMerge.target].text = lastMerge.targetText;
props.value.splice(lastMerge.source, 0, {
title: "",
text: lastMerge.sourceText,
ingredientReferences: [],
});
}
}
return {
...toRefs(state),
actionEvents,
activeRefs,
activeText,
getIngredientByRefId,
showTitleEditor,
mergeAbove,
openDialog,
setIngredientIds,
undoMerge,
toggleDisabled,
isChecked,
toggleShowTitle,
updateIndex,
autoSetReferences,
};
},
};
});
</script>