588 lines
15 KiB
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>
|