skillbox/client/src/components/content-blocks/ContentComponent.vue

387 lines
11 KiB
Vue

<template>
<div
:class="componentClass"
:data-scrollto="component.id"
:data-uuid="component.id"
data-cy="content-component"
ref="contentComponentDiv"
>
<bookmark-actions
:bookmarked="bookmarked"
:note="note"
:edit-mode="editMode"
v-if="showBookmarkActions"
@add-note="addNote(component.id)"
@edit-note="editNote"
@bookmark="bookmarkContent(!bookmarked)"
/>
<component
v-bind="component"
:parent="parent"
class="content-component__content"
:is="componentType"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue';
import { useStore } from 'vuex';
import { constructContentComponentBookmarkMutation } from '@/helpers/update-content-bookmark-mutation';
import { useMutation } from '@vue/apollo-composable';
import { HighlightNode } from '@/__generated__/graphql';
import Mark from 'mark.js';
import highlightSidebar from '@/helpers/highlight-sidebar';
export interface Props {
// todo: use useful types here
component: any;
parent: any;
highlights: HighlightNode[];
bookmarks: any[];
notes: any[];
root: string;
editMode: boolean;
}
import TextBlock from '@/components/content-blocks/TextBlock.vue';
import InstrumentWidget from '@/components/content-blocks/InstrumentWidget.vue';
import ImageBlock from '@/components/content-blocks/ImageBlock.vue';
import ImageUrlBlock from '@/components/content-blocks/ImageUrlBlock.vue';
import VideoBlock from '@/components/content-blocks/VideoBlock.vue';
import LinkBlock from '@/components/content-blocks/LinkBlock.vue';
import DocumentBlock from '@/components/content-blocks/DocumentBlock.vue';
import CmsDocumentBlock from '@/components/content-blocks/CmsDocumentBlock.vue';
import InfogramBlock from '@/components/content-blocks/InfogramBlock.vue';
import ThinglinkBlock from '@/components/content-blocks/ThinglinkBlock.vue';
import GeniallyBlock from '@/components/content-blocks/GeniallyBlock.vue';
import SubtitleBlock from '@/components/content-blocks/SubtitleBlock.vue';
import SectionTitleBlock from '@/components/content-blocks/SectionTitleBlock.vue';
import ContentListBlock from '@/components/content-blocks/ContentListBlock.vue';
import ModuleRoomSlug from '@/components/content-blocks/ModuleRoomSlug.vue';
import Assignment from '@/components/content-blocks/assignment/Assignment.vue';
import Survey from '@/components/content-blocks/SurveyBlock.vue';
import Solution from '@/components/content-blocks/Solution.vue';
import Instruction from '@/components/content-blocks/Instruction.vue';
import BookmarkActions from '@/components/notes/BookmarkActions.vue';
import popover from '@/helpers/popover';
import { graphql } from '@/__generated__';
type ContentComponentType =
| typeof TextBlock
| typeof InstrumentWidget
| typeof InstrumentWidget
| typeof ImageBlock
| typeof ImageUrlBlock
| typeof VideoBlock
| typeof LinkBlock
| typeof DocumentBlock
| typeof InfogramBlock
| typeof GeniallyBlock
| typeof SubtitleBlock
| typeof SectionTitleBlock
| typeof ContentListBlock
| typeof ModuleRoomSlug
| typeof ThinglinkBlock
| typeof CmsDocumentBlock
| typeof Survey
| typeof Solution
| typeof Instruction
| typeof Assignment;
const props = withDefaults(defineProps<Props>(), {
component: () => ({}),
parent: () => ({}),
bookmarks: () => [],
highlights: () => [],
notes: () => [],
root: '',
editMode: false,
});
const store = useStore();
const components: Record<string, ContentComponentType> = {
text_block: TextBlock,
basic_knowledge: InstrumentWidget, // for legacy
instrument: InstrumentWidget,
image_block: ImageBlock,
image_url_block: ImageUrlBlock,
video_block: VideoBlock,
link_block: LinkBlock,
document_block: DocumentBlock,
infogram_block: InfogramBlock,
genially_block: GeniallyBlock,
subtitle: SubtitleBlock,
section_title: SectionTitleBlock,
content_list: ContentListBlock,
module_room_slug: ModuleRoomSlug,
thinglink_block: ThinglinkBlock,
cms_document_block: CmsDocumentBlock,
survey: Survey,
solution: Solution,
instruction: Instruction,
assignment: Assignment,
};
const contentComponentDiv = ref<HTMLElement | null>(null);
const componentType = computed(() => {
return components[props.component.type] || '';
});
const filteredHighlights = computed(
() => props.highlights.filter((highlight) => highlight.contentUuid === props.component.id) || []
);
const bookmarked = computed(
() => props.bookmarks && !!props.bookmarks.find((bookmark) => bookmark.uuid === props.component.id)
);
const note = computed(() => {
const bookmark = props.bookmarks && props.bookmarks.find((bookmark) => bookmark.uuid === props.component.id);
return bookmark && bookmark.note;
});
const showBookmarkActions = computed(
() => props.component.type !== 'content_list' && props.component.type !== 'basic_knowledge' && !props.editMode
);
const componentClass = computed(() => {
let classes = ['content-component', `content-component--${props.component.type}`];
if (bookmarked.value) {
classes.push('content-component--bookmarked');
}
return classes;
});
const childElements = computed(() => {
if (contentComponentDiv.value) {
const parent = contentComponentDiv.value.querySelector('.content-component__content');
if (!parent) {
console.warn('Parent does not exist, this should not be possible');
// or can it be, if the children did not load yet, e.g. with a dynamic component?
return [];
}
// make a flat list from all <li>-elements and all the others
const elements = Array.from(parent.children).reduce((acc: Element[], current: Element) => {
if (current.tagName.toLowerCase() === 'ul') {
return [...acc, ...current.children];
}
return [...acc, current];
}, []);
return elements as HTMLElement[];
}
return [];
});
watch(
() => filteredHighlights.value.map((h) => h.color),
() => {
for (const paragraph of childElements.value) {
const instance = new Mark(paragraph);
instance.unmark();
}
markHighlights();
}
);
const addNote = (id: string) => {
const type = Object.prototype.hasOwnProperty.call(props.parent, '__typename')
? props.parent.__typename
: 'ContentBlockNode';
store.dispatch('addNote', {
content: id,
type,
block: props.root,
});
};
const editNote = () => {
store.dispatch('editNote', note);
};
const { mutation, variables, update, optimisticResponse } = constructContentComponentBookmarkMutation(
props.component.id,
bookmarked.value,
props.parent,
props.root
);
const { mutate: mutateBookmarkContent } = useMutation(mutation, {
update,
optimisticResponse,
});
const bookmarkContent = (bookmarked: boolean) => {
const newVars = {
input: {
...variables.input,
bookmarked,
},
};
mutateBookmarkContent(newVars);
};
const { mutate: doUpdateHighlight } = useMutation(
graphql(`
mutation UpdateHighlight($input: UpdateHighlightInput!) {
updateHighlight(input: $input) {
highlight {
...HighlightParts
}
}
}
`)
);
const { mutate: doDeleteHighlight } = useMutation(
graphql(`
mutation DeleteHighlight($input: DeleteHighlightInput!) {
deleteHighlight(input: $input) {
success
}
}
`)
);
const markHighlights = () => {
for (const highlight of filteredHighlights.value) {
const element = childElements.value[highlight.paragraphIndex];
const instance = new Mark(element);
const ranges: Mark.Range[] = [
{
start: highlight.startPosition,
length: highlight.selectionLength,
},
];
instance.markRanges(ranges, {
className: `highlight highlight--${highlight.color}`,
each: (newMark: HTMLElement) => {
newMark.dataset.id = highlight.id;
newMark.addEventListener('click', () => {
if (contentComponentDiv.value) {
popover.show({
wrapper: contentComponentDiv.value,
offsetTop: 0,
onChooseColor: (color: string) => {
doUpdateHighlight({
input: {
color: color,
id: highlight.id,
},
});
},
onDelete: () => {
console.log(`deleting ${highlight.id}`);
doDeleteHighlight(
{
input: {
id: highlight.id,
},
},
{
update: (
cache,
{
data: {
deleteHighlight: { success },
},
}
) => {
if (success) {
const fragment = graphql(`
fragment ContentBlockHighlightsWithIdOnlyFragment on ContentBlockNode {
highlights {
id
}
}
`);
const id = cache.identify({
__typename: 'ContentBlockNode',
id: highlight.contentBlock.id,
});
const contentBlock = cache.readFragment({
fragment,
id,
});
const data = {
...contentBlock,
highlights: contentBlock.highlights.filter((h) => h.id !== highlight.id),
};
cache.writeFragment({
fragment,
id,
data,
});
}
},
}
);
},
});
}
highlightSidebar.open({
highlight,
onUpdateText: (text: string) => {
doUpdateHighlight({
input: {
note: text,
id: highlight.id,
},
});
},
});
});
},
});
}
};
onMounted(() => {
console.log('onMounted ContentComponent called');
setTimeout(markHighlights, 500);
});
</script>
<style lang="postcss">
.highlight {
background-color: Highlight;
color: HighlightText;
cursor: pointer;
&--alpha {
background-color: var(--mark-alpha);
color: var(--color-charcoal-dark);
}
&--beta {
background-color: var(--mark-beta);
color: var(--color-charcoal-dark);
}
&--gamma {
background-color: var(--mark-gamma);
color: var(--color-charcoal-dark);
}
}
</style>
<style lang="postcss" scoped>
.content-component {
position: relative;
&--subtitle {
margin-top: 2 * var(--medium-spacing);
margin-bottom: var(--medium-spacing);
}
&--section_title {
margin-top: var(--section-spacing);
margin-bottom: var(--large-spacing);
}
&--text_block {
margin-bottom: var(--large-spacing);
}
&--document_block {
margin-bottom: var(--large-spacing);
}
}
</style>