skillbox/client/src/components/ContentBlock.vue

588 lines
15 KiB
Vue

<template>
<div
:class="{ 'hideable-element--greyed-out': isHidden }"
class="content-block__container hideable-element content-list__parent"
data-cy="content-block-div"
ref="contentBlockDiv"
>
<div
:class="specialClass"
:style="instrumentStyle"
class="content-block"
data-cy="content-block"
>
<div
class="block-actions"
v-if="canEditModule && !isInstrumentBlock"
>
<user-widget
v-bind="me"
class="block-actions__user-widget content-block__user-widget"
v-if="isMine"
/>
<more-options-widget>
<li
class="popover-links__link"
v-if="!isInstrumentBlock"
>
<popover-link
data-cy="duplicate-content-block-link"
text="Duplizieren"
@link-action="duplicateContentBlock()"
/>
</li>
<li
class="popover-links__link"
v-if="isMine"
>
<popover-link
data-cy="delete-content-block-link"
text="Löschen"
@link-action="deleteContentBlock()"
/>
</li>
<li
class="popover-links__link"
v-if="isMine"
>
<popover-link
text="Bearbeiten"
@link-action="editContentBlock()"
/>
</li>
</more-options-widget>
</div>
<div class="content-block__visibility">
<visibility-action
:block="contentBlock"
v-if="canEditModule"
/>
</div>
<!-- eslint-disable vue/no-v-html -->
<h3
class="content-block__instrument-label"
data-cy="instrument-label"
:style="instrumentLabelStyle"
v-if="instrumentLabel !== ''"
v-html="instrumentLabel"
/>
<h4
class="content-block__title"
v-if="!contentBlock.indent"
>
{{ contentBlock.title }}
<copy-link
v-if="!isInstrumentBlock"
:id="contentBlock.id"
/>
</h4>
<content-component
:component="component"
:root="root"
:parent="contentBlock"
:bookmarks="contentBlock.bookmarks"
:highlights="contentBlock.highlights"
:notes="contentBlock.notes"
:edit-mode="editMode"
v-for="component in contentBlocksWithContentLists.contents"
:key="component.id"
/>
</div>
<add-content-button
:where="{ after: contentBlock }"
v-if="canEditModule"
/>
</div>
</template>
<script setup lang="ts">
import { defineAsyncComponent, inject, onMounted, ref, computed, onUnmounted } from 'vue';
import { useMutation } from '@vue/apollo-composable';
import AddContentButton from '@/components/AddContentButton.vue';
import MoreOptionsWidget from '@/components/MoreOptionsWidget.vue';
import UserWidget from '@/components/UserWidget.vue';
import VisibilityAction from '@/components/visibility/VisibilityAction.vue';
import PopoverLink from '@/components/ui/PopoverLink.vue';
import CopyLink from '@/components/CopyLink.vue';
import { useRoute, useRouter } from 'vue-router';
import { hidden } from '@/helpers/visibility';
import { insertAtIndex, removeAtIndex } from '@/graphql/immutable-operations';
import { instrumentCategory } from '@/helpers/instrumentType';
import { CONTENT_TYPE } from '@/consts/types';
import { EDIT_CONTENT_BLOCK_PAGE } from '@/router/module.names';
import { getMe } from '@/mixins/me';
import DUPLICATE_CONTENT_BLOCK_MUTATION from '@/graphql/gql/mutations/duplicateContentBlock.gql';
import CHAPTER_QUERY from '@/graphql/gql/queries/chapterQuery.gql';
import DELETE_CONTENT_BLOCK_MUTATION from '@/graphql/gql/mutations/deleteContentBlock.gql';
import type { ExtendedContentBlockNode } from '@/@types';
import type { Modal } from '@/plugins/modal.types';
import { PAGE_LOAD_TIMEOUT } from '@/consts/navigation.consts';
import {
createHighlightCurry,
getSelectionHandler,
SelectionHandlerOptions,
SelectionHandlerType,
} from '@/helpers/highlight';
import { graphql } from '@/__generated__';
import highlightSidebar from '@/helpers/highlight-sidebar';
import { doUpdateHighlight } from '@/graphql/mutations';
import { AddContentHighlightArgument } from '@/__generated__/graphql';
export interface Props {
contentBlock: ExtendedContentBlockNode;
parent?: any;
editMode?: boolean;
}
const ContentComponent = defineAsyncComponent(() => import('@/components/content-blocks/ContentComponent.vue'));
const { me, schoolClass } = getMe();
const contentBlockDiv = ref<HTMLElement | null>(null);
const route = useRoute();
const router = useRouter();
const modal = inject('modal') as Modal;
const props = withDefaults(defineProps<Props>(), {
editMode: false,
});
const { mutate: duplicateContentBlock } = useMutation(DUPLICATE_CONTENT_BLOCK_MUTATION, () => ({
variables: {
input: {
id: props.contentBlock.id,
},
},
update: (
store,
{
data: {
duplicateContentBlock: { contentBlock },
},
}
) => {
const id = props.parent.id;
const contentBlockId = props.contentBlock.id;
if (contentBlock) {
const query = CHAPTER_QUERY;
const variables = {
id,
};
const { chapter }: any = store.readQuery({
query,
variables,
});
const index = chapter.contentBlocks.findIndex(
(contentBlock: ExtendedContentBlockNode) => contentBlock.id === contentBlockId
);
const contentBlocks = insertAtIndex(chapter.contentBlocks, index, contentBlock);
const data = {
chapter: {
...chapter,
contentBlocks,
},
};
store.writeQuery({
query,
variables,
data,
});
}
},
}));
const { mutate: doDeleteContentBlock } = useMutation(DELETE_CONTENT_BLOCK_MUTATION, () => ({
variables: {
input: {
id: props.contentBlock.id,
},
},
update: (
store,
{
data: {
deleteContentBlock: { success },
},
}
) => {
if (success) {
const query = CHAPTER_QUERY;
const variables = {
id: props.parent.id,
};
const { chapter }: any = store.readQuery({
query,
variables,
});
const index = chapter.contentBlocks.findIndex(
(contentBlock: ExtendedContentBlockNode) => contentBlock.id === props.contentBlock.id
);
if (index < 0) {
throw Error('ContentBlock not found');
}
const contentBlocks = removeAtIndex(chapter.contentBlocks, index);
const data = {
chapter: {
...chapter,
contentBlocks,
},
};
store.writeQuery({
query,
variables,
data,
});
}
},
}));
graphql(`
fragment HighlightParts on HighlightNode {
id
contentIndex
paragraphIndex
selectionLength
contentUuid
startPosition
color
note {
text
}
text
page {
# only one of them should be necessary, but the client somehow doesn't like just the Node inline fragment
__typename
... on ContentBlockNode {
id
path
}
... on InstrumentNode {
id
slug
}
... on ModuleNode {
id
slug
path
}
... on ChapterNode {
id
path
slug
}
}
}
`);
const contentBlockHighlightsFragment = graphql(`
fragment ContentBlockHighlightsFragment on ContentBlockNode {
id
__typename
highlights {
...HighlightParts
}
}
`);
let selectionHandler: SelectionHandlerType;
const createHighlight = createHighlightCurry({
fragment: contentBlockHighlightsFragment,
fragmentName: 'ContentBlockHighlightsFragment',
cacheSignature: { id: props.contentBlock.id, __typename: 'ContentBlockNode' },
isContentHighlight: true,
});
const isNested = computed(() => props.contentBlock.root); // if it's nested, a the parent has the root propert
onMounted(() => {
const element = contentBlockDiv.value;
if (element !== null) {
if (route.hash === `#${props.contentBlock.id}`) {
setTimeout(() => {
const rect = element.getBoundingClientRect();
window.scrollTo({
top: rect.y,
behavior: 'smooth',
});
}, PAGE_LOAD_TIMEOUT);
}
const options: SelectionHandlerOptions = {
el: element,
page: props.contentBlock,
onChangeColor: (newHighlight: AddContentHighlightArgument) => {
createHighlight(newHighlight);
},
onCreateNote: (newHighlight: AddContentHighlightArgument) => {
// we also open the sidebar when clicking on the note icon
createHighlight(newHighlight).then((highlight) => {
highlightSidebar.open({
highlight,
onUpdateText: (text: string) => {
doUpdateHighlight({
input: {
note: text,
id: highlight.id,
},
});
},
});
});
},
};
selectionHandler = getSelectionHandler(options);
// add the listener from highlights
if (!isNested.value) {
element.addEventListener('mouseup', selectionHandler);
}
}
});
onUnmounted(() => {
const element = contentBlockDiv.value;
if (element !== null) {
element.removeEventListener('mouseup', selectionHandler);
}
});
// methods
const createContentListOrBlocks = (contentList: any[]) => {
return [
{
type: 'content_list',
contents: contentList,
id: contentList[0].id,
},
];
};
const editContentBlock = () => {
const route = {
name: EDIT_CONTENT_BLOCK_PAGE,
params: {
id: props.contentBlock.id,
},
};
router.push(route);
};
const deleteContentBlock = () => {
modal
.open('confirm')
.then(() => {
doDeleteContentBlock();
})
.catch();
};
// computed properties
const canEditModule = computed(() => !props.contentBlock.indent && props.editMode);
const specialClass = computed(() => `content-block--${props.contentBlock.type.toLowerCase()}`);
const isInstrumentBlock = computed(() => !!props.contentBlock.instrumentCategory);
// todo: use dynamic css class with v-bind once we're on Vue 3: https://vuejs.org/api/sfc-css-features.html#v-bind-in-css
const instrumentStyle = computed(() =>
isInstrumentBlock.value ? { backgroundColor: props.contentBlock.instrumentCategory?.background || '' } : {}
);
const instrumentLabel = computed(() => {
const contentType = props.contentBlock.type.toLowerCase();
if (contentType.startsWith('base')) {
// all legacy instruments start with `base`
return instrumentCategory(contentType);
}
if (isInstrumentBlock.value) {
return instrumentCategory(props.contentBlock.instrumentCategory?.name);
}
return '';
});
// todo: use dynamic css class with v-bind once we're on Vue 3: https://vuejs.org/api/sfc-css-features.html#v-bind-in-css
const instrumentLabelStyle = computed(() => {
if (isInstrumentBlock.value) {
return {
color: props.contentBlock.instrumentCategory?.foreground,
};
}
return {};
});
const isMine = computed(() => props.contentBlock.mine);
const contentBlocksWithContentLists = computed(() => {
/*
collects all content_list_items in content_lists:
{
text_block,
content_list_item: [contents...],
content_list_item: [contents...],
text_block
} becomes
{
text_block,
content_list: [content_list_item: [contents...], content_list_item: [contents...]],
text_block
}
*/
let contentList: any[] = [];
let newContent = props.contentBlock.contents.reduce((newContents, content, index) => {
// collect content_list_items
if (content.type === 'content_list_item') {
contentList = [...contentList, content];
if (index === props.contentBlock.contents.length - 1) {
// content is last element of contents array
let updatedContent = [...newContents, ...createContentListOrBlocks(contentList)];
return updatedContent;
}
return newContents;
} else {
// handle all other items and reset current content_list if necessary
if (contentList.length !== 0) {
newContents = [...newContents, ...createContentListOrBlocks(contentList), content];
contentList = [];
return newContents;
} else {
return [...newContents, content];
}
}
}, []);
return Object.assign({}, props.contentBlock, {
contents: newContent,
});
});
const isHidden = computed(() =>
hidden({
block: props.contentBlock,
schoolClass: schoolClass.value,
type: CONTENT_TYPE,
})
);
// we need the root content block id, not the generated content block if inside a content list block
const root = computed(() => (props.contentBlock.root ? props.contentBlock.root : props.contentBlock.id));
</script>
<style scoped lang="scss">
@import 'styles/helpers';
.content-block {
margin-bottom: $section-spacing;
position: relative;
&__container {
position: relative;
}
&__title {
line-height: 1.5;
margin-top: -0.5rem; // to offset the 1.5 line height, it leaves a padding on top
display: flex;
justify-content: space-between;
padding-bottom: calc($small-spacing / 2);
}
&__instrument-label {
margin-bottom: $medium-spacing;
@include regular-text();
}
&__action-button {
cursor: pointer;
}
&__user-widget {
margin-right: 0;
}
&--base_communication {
@include content-box($color-accent-1-list);
.content-block__instrument-label {
color: $color-accent-1-dark;
}
}
&--task {
@include light-border(bottom);
.content-block__title {
color: $color-brand;
margin-top: $default-padding;
margin-bottom: $large-spacing;
@include light-border(bottom);
@include desktop {
margin-top: 0;
}
}
}
&--base_society {
@include content-box($color-accent-2-list);
.content-block__instrument-label {
color: $color-accent-2-dark;
}
}
&--base_interdisciplinary {
@include content-box($color-accent-4-list);
.content-block__instrument-label {
color: $color-accent-4-dark;
}
}
&--instrument {
@include content-box-base;
:deep(.bookmark-actions) {
// default is -150px from bookmak actions - 15px from padding of Instrument Widget;
right: -165px;
}
}
:deep(p) {
line-height: 1.5;
margin-bottom: 1em;
&:last-child {
margin-bottom: 0;
}
}
:deep(.text-block) {
ul {
@include list-parent;
}
li {
@include list-child;
line-height: 1.5;
}
}
}
</style>