Add highlight query to client

This commit is contained in:
Ramon Wenger 2024-01-11 16:14:22 +01:00
parent a6974853ef
commit 9213d57be5
4 changed files with 165 additions and 162 deletions

View File

@ -83,6 +83,7 @@
:root="root"
:parent="contentBlock"
:bookmarks="contentBlock.bookmarks"
:highlights="contentBlock.highlights"
:notes="contentBlock.notes"
:edit-mode="editMode"
v-for="component in contentBlocksWithContentLists.contents"
@ -260,7 +261,9 @@ onMounted(() => {
}
// add the listener from highlights
element.addEventListener('mouseup', getSelectionHandler(element, props.contentBlock));
element.addEventListener('mouseup', getSelectionHandler(element, props.contentBlock), (newHighlight) => {
console.log(newHighlight);
});
}
});

View File

@ -4,6 +4,7 @@
:data-scrollto="component.id"
:data-uuid="component.id"
data-cy="content-component"
ref="contentComponentDiv"
>
<bookmark-actions
:bookmarked="bookmarked"
@ -14,84 +15,51 @@
@edit-note="editNote"
@bookmark="bookmarkContent(!bookmarked)"
/>
<component v-bind="component" :parent="parent" :is="componentType" />
<component
v-bind="component"
:parent="parent"
:is="componentType"
/>
</div>
</template>
<script setup lang="ts">
import { defineAsyncComponent, ref, computed, onMounted } from "vue";
import { useStore } from "vuex";
import { constructContentComponentBookmarkMutation } from "@/helpers/update-content-bookmark-mutation";
import { useMutation } from "@vue/apollo-composable";
import { defineAsyncComponent, ref, computed, onMounted } 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 * as Mark from 'mark.js';
export interface Props {
component: any;
parent: any;
highlights: HighlightNode[];
bookmarks: any[];
notes: any[];
root: string;
editMode: boolean;
}
const TextBlock = defineAsyncComponent(
() => import("@/components/content-blocks/TextBlock.vue")
);
const InstrumentWidget = defineAsyncComponent(
() => import("@/components/content-blocks/InstrumentWidget.vue")
);
const ImageBlock = defineAsyncComponent(
() => import("@/components/content-blocks/ImageBlock.vue")
);
const ImageUrlBlock = defineAsyncComponent(
() => import("@/components/content-blocks/ImageUrlBlock.vue")
);
const VideoBlock = defineAsyncComponent(
() => import("@/components/content-blocks/VideoBlock.vue")
);
const LinkBlock = defineAsyncComponent(
() => import("@/components/content-blocks/LinkBlock.vue")
);
const DocumentBlock = defineAsyncComponent(
() => import("@/components/content-blocks/DocumentBlock.vue")
);
const CmsDocumentBlock = defineAsyncComponent(
() => import("@/components/content-blocks/CmsDocumentBlock.vue")
);
const InfogramBlock = defineAsyncComponent(
() => import("@/components/content-blocks/InfogramBlock.vue")
);
const ThinglinkBlock = defineAsyncComponent(
() => import("@/components/content-blocks/ThinglinkBlock.vue")
);
const GeniallyBlock = defineAsyncComponent(
() => import("@/components/content-blocks/GeniallyBlock.vue")
);
const SubtitleBlock = defineAsyncComponent(
() => import("@/components/content-blocks/SubtitleBlock.vue")
);
const SectionTitleBlock = defineAsyncComponent(
() => import("@/components/content-blocks/SectionTitleBlock.vue")
);
const ContentListBlock = defineAsyncComponent(
() => import("@/components/content-blocks/ContentListBlock.vue")
);
const ModuleRoomSlug = defineAsyncComponent(
() => import("@/components/content-blocks/ModuleRoomSlug.vue")
);
const Assignment = defineAsyncComponent(
() => import("@/components/content-blocks/assignment/Assignment.vue")
);
const Survey = defineAsyncComponent(
() => import("@/components/content-blocks/SurveyBlock.vue")
);
const Solution = defineAsyncComponent(
() => import("@/components/content-blocks/Solution.vue")
);
const Instruction = defineAsyncComponent(
() => import("@/components/content-blocks/Instruction.vue")
);
const BookmarkActions = defineAsyncComponent(
() => import("@/components/notes/BookmarkActions.vue")
);
const TextBlock = defineAsyncComponent(() => import('@/components/content-blocks/TextBlock.vue'));
const InstrumentWidget = defineAsyncComponent(() => import('@/components/content-blocks/InstrumentWidget.vue'));
const ImageBlock = defineAsyncComponent(() => import('@/components/content-blocks/ImageBlock.vue'));
const ImageUrlBlock = defineAsyncComponent(() => import('@/components/content-blocks/ImageUrlBlock.vue'));
const VideoBlock = defineAsyncComponent(() => import('@/components/content-blocks/VideoBlock.vue'));
const LinkBlock = defineAsyncComponent(() => import('@/components/content-blocks/LinkBlock.vue'));
const DocumentBlock = defineAsyncComponent(() => import('@/components/content-blocks/DocumentBlock.vue'));
const CmsDocumentBlock = defineAsyncComponent(() => import('@/components/content-blocks/CmsDocumentBlock.vue'));
const InfogramBlock = defineAsyncComponent(() => import('@/components/content-blocks/InfogramBlock.vue'));
const ThinglinkBlock = defineAsyncComponent(() => import('@/components/content-blocks/ThinglinkBlock.vue'));
const GeniallyBlock = defineAsyncComponent(() => import('@/components/content-blocks/GeniallyBlock.vue'));
const SubtitleBlock = defineAsyncComponent(() => import('@/components/content-blocks/SubtitleBlock.vue'));
const SectionTitleBlock = defineAsyncComponent(() => import('@/components/content-blocks/SectionTitleBlock.vue'));
const ContentListBlock = defineAsyncComponent(() => import('@/components/content-blocks/ContentListBlock.vue'));
const ModuleRoomSlug = defineAsyncComponent(() => import('@/components/content-blocks/ModuleRoomSlug.vue'));
const Assignment = defineAsyncComponent(() => import('@/components/content-blocks/assignment/Assignment.vue'));
const Survey = defineAsyncComponent(() => import('@/components/content-blocks/SurveyBlock.vue'));
const Solution = defineAsyncComponent(() => import('@/components/content-blocks/Solution.vue'));
const Instruction = defineAsyncComponent(() => import('@/components/content-blocks/Instruction.vue'));
const BookmarkActions = defineAsyncComponent(() => import('@/components/notes/BookmarkActions.vue'));
type ContentComponentType =
| typeof TextBlock
@ -119,8 +87,9 @@ const props = withDefaults(defineProps<Props>(), {
component: () => ({}),
parent: () => ({}),
bookmarks: () => [],
highlights: () => [],
notes: () => [],
root: "",
root: '',
editMode: false,
});
@ -149,44 +118,39 @@ const components: Record<string, ContentComponentType> = {
assignment: Assignment,
};
const contentComponentDiv = ref<HTMLElement | null>(null);
const componentType = computed(() => {
return components[props.component.type] || "";
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)
() => 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);
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
() => props.component.type !== 'content_list' && props.component.type !== 'basic_knowledge' && !props.editMode
);
const componentClass = computed(() => {
let classes = [
"content-component",
`content-component--${props.component.type}`,
];
let classes = ['content-component', `content-component--${props.component.type}`];
if (bookmarked.value) {
classes.push("content-component--bookmarked");
classes.push('content-component--bookmarked');
}
return classes;
});
const addNote = (id: string) => {
const type = Object.prototype.hasOwnProperty.call(props.parent, "__typename")
const type = Object.prototype.hasOwnProperty.call(props.parent, '__typename')
? props.parent.__typename
: "ContentBlockNode";
: 'ContentBlockNode';
store.dispatch("addNote", {
store.dispatch('addNote', {
content: id,
type,
block: props.root,
@ -194,16 +158,15 @@ const addNote = (id: string) => {
};
const editNote = () => {
store.dispatch("editNote", note);
store.dispatch('editNote', note);
};
const { mutation, variables, update, optimisticResponse } =
constructContentComponentBookmarkMutation(
props.component.id,
bookmarked.value,
props.parent,
props.root
);
const { mutation, variables, update, optimisticResponse } = constructContentComponentBookmarkMutation(
props.component.id,
bookmarked.value,
props.parent,
props.root
);
const { mutate: mutateBookmarkContent } = useMutation(mutation, {
update,
optimisticResponse,
@ -219,10 +182,35 @@ const bookmarkContent = (bookmarked: boolean) => {
console.log(newVars);
mutateBookmarkContent(newVars);
};
onMounted(() => {
setTimeout(() => {
if (contentComponentDiv.value) {
const paragraphs = contentComponentDiv.value.getElementsByTagName('p');
for (const highlight of filteredHighlights.value) {
console.log(paragraphs);
console.log(highlight);
const element = paragraphs[highlight.paragraphIndex];
console.log(element);
const instance = new Mark(element);
console.log(instance);
const ranges: Mark.Range[] = [
{
start: highlight.startPosition,
length: highlight.startPosition,
},
];
console.log(ranges);
instance.markRanges(ranges);
}
}
}, 2000);
});
</script>
<style lang="scss" scoped>
@import "styles/helpers";
@import 'styles/helpers';
.content-component {
position: relative;

View File

@ -14,7 +14,7 @@ import popover from '@/helpers/popover';
// Which start and endpoint of the selection? Or rather start and length?
// Also, we probably want to store the text of the selection at least, and maybe even some more for context?
interface Highlight {
export interface Highlight {
contentBlock: string; // the id
contentIndex: number; // which content of the .contents array do I come from?
contentUUID: string; // the wagtail UUID of the content element
@ -22,6 +22,7 @@ interface Highlight {
startPosition: number; // start of the selection
selectionLength: number; // length of the selection
text: string; // text that was actually selected
color?: string;
}
const TOP_TRESHOLD = 0;
@ -86,78 +87,84 @@ const findPositionInParent = (element: HTMLElement, className: string = 'content
return children.indexOf(element);
};
export const getSelectionHandler = (el: HTMLElement, contentBlock: ContentBlockNode) => (_e: Event) => {
const rect = el.getBoundingClientRect();
if (isInsideViewport(rect.top) || isInsideViewport(rect.bottom)) {
// the listener only does something if the `el` is visible in the viewport, to save resources
export const getSelectionHandler =
(el: HTMLElement, contentBlock: ContentBlockNode, onUpdateHighlight: (highlight: any) => void = () => {}) =>
(_e: Event) => {
const rect = el.getBoundingClientRect();
if (isInsideViewport(rect.top) || isInsideViewport(rect.bottom)) {
// the listener only does something if the `el` is visible in the viewport, to save resources
// now we check if the selection is inside our container
// now we check if the selection is inside our container
const selection = rangy.getSelection();
if (selection.rangeCount && !selection.isCollapsed) {
const range = selection.getRangeAt(0);
if (range.intersectsNode(el)) {
// the selection is inside our container, so we can do something with it
const { startContainer, endContainer } = range;
const paragraph = findClosestAncestorWithTag(startContainer, 'p');
const endAncestor = findClosestAncestorWithTag(endContainer, 'p');
const selection = rangy.getSelection();
if (selection.rangeCount && !selection.isCollapsed) {
const range = selection.getRangeAt(0);
if (range.intersectsNode(el)) {
// the selection is inside our container, so we can do something with it
const { startContainer, endContainer } = range;
const paragraph = findClosestAncestorWithTag(startContainer, 'p');
const endAncestor = findClosestAncestorWithTag(endContainer, 'p');
if (paragraph && paragraph === endAncestor) {
// todo: rename this variable, `parent` is not correct anymore
const contentComponent = findClosestAncestorWithClass(startContainer, 'content-component');
if (contentComponent) {
// our selection is wholly inside the container node, we continue with it
const position = findPositionInParent(contentComponent);
const siblings = Array.from(paragraph.parentElement?.children || []);
const positionInTextBlock = siblings.indexOf(paragraph);
if (!paragraph) {
throw Error('This should not be happening, but there was no paragraph');
}
if (paragraph === endAncestor) {
const contentComponent = findClosestAncestorWithClass(startContainer, 'content-component');
if (contentComponent) {
// our selection is wholly inside the container node, we continue with it
const position = findPositionInParent(contentComponent);
const siblings = Array.from(paragraph.parentElement?.children || []);
const positionInTextBlock = siblings.indexOf(paragraph);
const numOfParagraphs = Array.from(contentComponent.children[1].children).filter(
(child) => child.nodeName.toLowerCase() === 'p'
).length;
const numOfParagraphs = Array.from(contentComponent.children[1].children).filter(
(child) => child.nodeName.toLowerCase() === 'p'
).length;
const { start, end } = range.toCharacterRange(paragraph);
const uuid = contentComponent.dataset.uuid || '';
const highlightedText: Highlight = {
contentBlock: contentBlock.id,
contentIndex: position,
contentUUID: uuid,
startPosition: start,
paragraphIndex: positionInTextBlock,
selectionLength: end - start,
text: range.toString(),
};
console.log(highlightedText);
const { start, end } = range.toCharacterRange(paragraph);
const uuid = contentComponent.dataset.uuid || '';
const highlightedText: Highlight = {
contentBlock: contentBlock.id,
contentIndex: position,
contentUUID: uuid,
startPosition: start,
paragraphIndex: positionInTextBlock,
selectionLength: end - start,
text: range.toString(),
};
console.log(highlightedText);
const positionOfContentBlock = el.getBoundingClientRect();
const positionOfSelection = paragraph?.getBoundingClientRect();
const offsetTop = positionOfSelection.top - positionOfContentBlock.top;
popover
.show({
wrapper: el,
offsetTop,
onChooseColor: (color: string) => {
console.log('chosenColor', color);
// const newHighlight: Highlight = {
// contentBlock: contentBlock.id,
// contentIndex:
//
// };
},
})
.then(() => {
console.log('confirmed');
})
.catch(() => {
console.log('canceled');
});
// todo: how do we save this? maybe we need to move away from the directive and do this inside the mounted
// and unmounted hooks of the ContentBlock
const positionOfContentBlock = el.getBoundingClientRect();
const positionOfSelection = paragraph?.getBoundingClientRect();
const offsetTop = positionOfSelection.top - positionOfContentBlock.top;
popover
.show({
wrapper: el,
offsetTop,
onChooseColor: (color: string) => {
console.log('chosenColor', color);
highlightedText.color = color;
// const newHighlight: Highlight = {
// contentBlock: contentBlock.id,
// contentIndex:
//
// };
},
})
.then(() => {
console.log('confirmed here');
onUpdateHighlight();
})
.catch(() => {
console.log('canceled');
});
// todo: how do we save this? maybe we need to move away from the directive and do this inside the mounted
// and unmounted hooks of the ContentBlock
}
}
}
}
}
}
};
};
const highlight = (element: HTMLElement, ranges: Mark.Range[]) => {
// todo: make the ContentComponent / TextBlock component handle the highlighting
@ -177,11 +184,7 @@ const mounted = (el: HTMLElement, binding: DirectiveBinding) => {
// code inside this setTimeout would run too early and yield no elements
setTimeout(() => {
const elements = el.querySelectorAll<HTMLElement>('.content-component');
// console.log(el);
// console.log(elements);
for (const element of elements) {
// console.log(element);
// console.log(typeof element);
highlight(element, demoRanges);
}
// highlight(el, demoRanges);

View File

@ -3,6 +3,15 @@ fragment ContentBlockParts on ContentBlockNode {
slug
userCreated
mine
highlights {
contentIndex
paragraphIndex
selectionLength
contentUuid
startPosition
text
color
}
instrumentCategory {
id
foreground