skillbox/client/src/components/Chapter.vue

364 lines
8.8 KiB
Vue

<template>
<div
:data-scrollto="chapter.id"
class="chapter"
data-cy="chapter"
ref="wrapper"
>
<div
:class="{ 'hideable-element--greyed-out': titleGreyedOut }"
class="hideable-element"
v-if="!titleHidden"
>
<h3
class="chapter__title"
:id="'chapter-' + index"
>
{{ chapter.title }}
<copy-link :id="chapter.id" />
</h3>
</div>
<visibility-action
:block="chapter"
type="chapter-title"
v-if="editMode"
/>
<bookmark-actions
:bookmarked="!!chapter.bookmark"
:note="note"
:edit-mode="editMode"
class="chapter__bookmark-actions"
data-cy="chapter-bookmark-actions"
@add-note="addNote"
@edit-note="editNote"
@bookmark="bookmark()"
/>
<div
:class="{ 'hideable-element--greyed-out': descriptionGreyedOut }"
class="chapter__intro intro hideable-element"
data-cy="chapter-intro"
v-if="!descriptionHidden"
ref="chapterIntro"
>
<visibility-action
:block="chapter"
:chapter="true"
type="chapter-description"
v-if="editMode"
/>
<p
data-cy="chapter-description"
class="chapter__description"
>
{{ chapter.description }}
</p>
</div>
<add-content-button
:where="{ parent: chapter }"
v-if="editMode"
/>
<content-block
:content-block="contentBlock"
:parent="chapter"
:edit-mode="editMode"
v-for="contentBlock in filteredContentBlocks"
:key="contentBlock.id"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import ContentBlock from '@/components/ContentBlock.vue';
import AddContentButton from '@/components/AddContentButton.vue';
import BookmarkActions from '@/components/notes/BookmarkActions.vue';
import VisibilityAction from '@/components/visibility/VisibilityAction.vue';
import CopyLink from '@/components/CopyLink.vue';
import { hidden } from '@/helpers/visibility';
import { CHAPTER_DESCRIPTION_TYPE, CHAPTER_TITLE_TYPE, CONTENT_TYPE } from '@/consts/types';
import { useStore } from 'vuex';
import UPDATE_CHAPTER_BOOKMARK_MUTATION from '@/graphql/gql/mutations/updateChapterBookmark.gql';
import CHAPTER_QUERY from '@/graphql/gql/queries/chapterQuery.gql';
import { getMe } from '@/mixins/me';
import { useMutation } from '@vue/apollo-composable';
import { useRoute } from 'vue-router';
import { PAGE_LOAD_TIMEOUT } from '@/consts/navigation.consts';
import {
SelectionHandlerOptions,
SelectionHandlerType,
createHighlightCurry,
getSelectionHandler,
markHighlight,
} from '@/helpers/highlight';
import { onUnmounted } from 'vue';
import { AddHighlightArgument, ChapterNode, HighlightNode } from '@/__generated__/graphql';
import { graphql } from '@/__generated__';
import highlightSidebar from '@/helpers/highlight-sidebar';
import { doUpdateHighlight } from '@/graphql/mutations';
import { watch } from 'vue';
import Mark from 'mark.js';
export interface Props {
chapter: ChapterNode;
index: number;
editMode: boolean;
}
const { schoolClass } = getMe();
const store = useStore();
const route = useRoute();
const wrapper = ref<HTMLElement | null>(null);
const chapterIntro = ref<HTMLElement | null>(null);
const props = withDefaults(defineProps<Props>(), {
index: 0,
editMode: false,
});
const chapterHighlightsFragment = graphql(`
fragment ChapterHighlightsFragment on ChapterNode {
id
__typename
highlights {
...HighlightParts
}
}
`);
const createHighlight = createHighlightCurry({
fragment: chapterHighlightsFragment,
fragmentName: 'ChapterHighlightsFragment',
cacheSignature: { id: props.chapter.id, __typename: 'ChapterNode' },
isContentHighlight: false,
});
const markHighlights = () => {
if (props.chapter.highlights && chapterIntro.value) {
for (const highlight of props.chapter.highlights) {
const highlightNode = highlight as HighlightNode;
const element = chapterIntro.value.children[highlightNode.paragraphIndex] as HTMLElement;
markHighlight(highlightNode, element, element);
}
}
};
let selectionHandler: SelectionHandlerType;
onMounted(() => {
const element = wrapper.value;
if (element !== null) {
if (route.hash === `#${props.chapter.id}`) {
setTimeout(() => {
const rect = element.getBoundingClientRect();
window.scrollTo({ top: rect.y, behavior: 'smooth' });
}, PAGE_LOAD_TIMEOUT);
}
}
const intro = chapterIntro.value;
if (intro !== null) {
const options: SelectionHandlerOptions = {
el: intro,
page: props.chapter,
parentSelector: 'chapter__intro',
onChangeColor: (newHighlight: AddHighlightArgument) => {
createHighlight(newHighlight);
},
onCreateNote: async (newHighlight: AddHighlightArgument) => {
// we also open the sidebar when clicking on the note icon
const highlight = await createHighlight(newHighlight);
highlightSidebar.open({
highlight,
onUpdateText: (text: string) => {
doUpdateHighlight({
input: {
note: text,
id: highlight.id,
},
});
},
});
},
};
selectionHandler = getSelectionHandler(options);
intro.addEventListener('mouseup', selectionHandler);
}
markHighlights();
});
onUnmounted(() => {
const intro = chapterIntro.value;
if (intro !== null) {
intro.removeEventListener('mouseup', selectionHandler);
}
});
const filteredContentBlocks = computed(() => {
if (!(props.chapter && props.chapter.contentBlocks)) {
return [];
}
if (props.editMode) {
return props.chapter.contentBlocks;
}
return props.chapter.contentBlocks.filter(
(contentBlock) =>
!hidden({
block: contentBlock,
schoolClass: schoolClass.value,
type: CONTENT_TYPE,
})
);
});
const note = computed(() => {
if (props.chapter && props.chapter.bookmark) {
return props.chapter.bookmark.note;
}
return false;
});
const titleGreyedOut = computed(() => {
return textHidden(CHAPTER_TITLE_TYPE) && props.editMode;
});
const titleHidden = computed(() => {
// never hidden when editing the module
if (props.chapter.titleHidden === true) {
return true;
}
return textHidden(CHAPTER_TITLE_TYPE) && !props.editMode;
});
const descriptionGreyedOut = computed(() => {
return textHidden(CHAPTER_DESCRIPTION_TYPE) && props.editMode;
});
const descriptionHidden = computed(() => {
// never hidden when editing the module
if (props.chapter.descriptionHidden === true) {
return true;
}
return textHidden(CHAPTER_DESCRIPTION_TYPE) && !props.editMode;
});
const { mutate: bookmark } = useMutation(UPDATE_CHAPTER_BOOKMARK_MUTATION, () => {
const bookmarked = !props.chapter.bookmark;
const id = props.chapter.id;
return {
variables: {
input: {
chapter: id,
bookmarked,
},
},
update: (store: any) => {
const query = CHAPTER_QUERY;
const variables = { id };
const { chapter } = store.readQuery({
query,
variables,
});
let bookmark;
if (bookmarked) {
bookmark = {
__typename: 'ChapterBookmarkNode',
note: null,
};
} else {
bookmark = null;
}
const data = {
chapter: {
...chapter,
bookmark,
},
};
store.writeQuery({
data,
query,
variables,
});
},
optimisticResponse: {
__typename: 'Mutation',
updateChapterBookmark: {
__typename: 'UpdateChapterBookmarkPayload',
success: true,
},
},
};
});
const addNote = (id: string) => {
store.dispatch('addNote', {
content: id,
parent: props.chapter.id,
});
};
const editNote = () => {
store.dispatch('editNote', props.chapter.bookmark.note);
};
const textHidden = (type: string) => {
return hidden({
block: props.chapter,
schoolClass: schoolClass.value,
type,
});
};
const unmark = () => {
for (const paragraph of chapterIntro.value.children) {
const instance = new Mark(paragraph);
instance.unmark();
}
};
const highlights = computed(() => {
return props.chapter ? props.chapter.highlights : [];
});
watch(
() => highlights.value?.filter((h) => h.color),
() => {
unmark();
markHighlights();
}
);
</script>
<style lang="scss">
/* todo: re-add `scoped`, same as in ContentBlock.vue */
@import 'styles/helpers';
.chapter {
position: relative;
&__bookmark-actions {
margin-top: 3px;
}
&__intro {
position: relative;
}
&__title {
display: flex;
justify-content: space-between;
align-items: center;
}
&__description {
@include lead-paragraph;
margin-bottom: $large-spacing;
}
}
</style>