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" :root="root"
:parent="contentBlock" :parent="contentBlock"
:bookmarks="contentBlock.bookmarks" :bookmarks="contentBlock.bookmarks"
:highlights="contentBlock.highlights"
:notes="contentBlock.notes" :notes="contentBlock.notes"
:edit-mode="editMode" :edit-mode="editMode"
v-for="component in contentBlocksWithContentLists.contents" v-for="component in contentBlocksWithContentLists.contents"
@ -260,7 +261,9 @@ onMounted(() => {
} }
// add the listener from highlights // 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-scrollto="component.id"
:data-uuid="component.id" :data-uuid="component.id"
data-cy="content-component" data-cy="content-component"
ref="contentComponentDiv"
> >
<bookmark-actions <bookmark-actions
:bookmarked="bookmarked" :bookmarked="bookmarked"
@ -14,84 +15,51 @@
@edit-note="editNote" @edit-note="editNote"
@bookmark="bookmarkContent(!bookmarked)" @bookmark="bookmarkContent(!bookmarked)"
/> />
<component v-bind="component" :parent="parent" :is="componentType" /> <component
v-bind="component"
:parent="parent"
:is="componentType"
/>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { defineAsyncComponent, ref, computed, onMounted } from "vue"; import { defineAsyncComponent, ref, computed, onMounted } from 'vue';
import { useStore } from "vuex"; import { useStore } from 'vuex';
import { constructContentComponentBookmarkMutation } from "@/helpers/update-content-bookmark-mutation"; import { constructContentComponentBookmarkMutation } from '@/helpers/update-content-bookmark-mutation';
import { useMutation } from "@vue/apollo-composable"; import { useMutation } from '@vue/apollo-composable';
import { HighlightNode } from '@/__generated__/graphql';
import * as Mark from 'mark.js';
export interface Props { export interface Props {
component: any; component: any;
parent: any; parent: any;
highlights: HighlightNode[];
bookmarks: any[]; bookmarks: any[];
notes: any[]; notes: any[];
root: string; root: string;
editMode: boolean; editMode: boolean;
} }
const TextBlock = defineAsyncComponent( const TextBlock = defineAsyncComponent(() => import('@/components/content-blocks/TextBlock.vue'));
() => 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 InstrumentWidget = defineAsyncComponent( const ImageUrlBlock = defineAsyncComponent(() => import('@/components/content-blocks/ImageUrlBlock.vue'));
() => import("@/components/content-blocks/InstrumentWidget.vue") const VideoBlock = defineAsyncComponent(() => import('@/components/content-blocks/VideoBlock.vue'));
); const LinkBlock = defineAsyncComponent(() => import('@/components/content-blocks/LinkBlock.vue'));
const ImageBlock = defineAsyncComponent( const DocumentBlock = defineAsyncComponent(() => import('@/components/content-blocks/DocumentBlock.vue'));
() => import("@/components/content-blocks/ImageBlock.vue") const CmsDocumentBlock = defineAsyncComponent(() => import('@/components/content-blocks/CmsDocumentBlock.vue'));
); const InfogramBlock = defineAsyncComponent(() => import('@/components/content-blocks/InfogramBlock.vue'));
const ImageUrlBlock = defineAsyncComponent( const ThinglinkBlock = defineAsyncComponent(() => import('@/components/content-blocks/ThinglinkBlock.vue'));
() => import("@/components/content-blocks/ImageUrlBlock.vue") const GeniallyBlock = defineAsyncComponent(() => import('@/components/content-blocks/GeniallyBlock.vue'));
); const SubtitleBlock = defineAsyncComponent(() => import('@/components/content-blocks/SubtitleBlock.vue'));
const VideoBlock = defineAsyncComponent( const SectionTitleBlock = defineAsyncComponent(() => import('@/components/content-blocks/SectionTitleBlock.vue'));
() => import("@/components/content-blocks/VideoBlock.vue") const ContentListBlock = defineAsyncComponent(() => import('@/components/content-blocks/ContentListBlock.vue'));
); const ModuleRoomSlug = defineAsyncComponent(() => import('@/components/content-blocks/ModuleRoomSlug.vue'));
const LinkBlock = defineAsyncComponent( const Assignment = defineAsyncComponent(() => import('@/components/content-blocks/assignment/Assignment.vue'));
() => import("@/components/content-blocks/LinkBlock.vue") const Survey = defineAsyncComponent(() => import('@/components/content-blocks/SurveyBlock.vue'));
); const Solution = defineAsyncComponent(() => import('@/components/content-blocks/Solution.vue'));
const DocumentBlock = defineAsyncComponent( const Instruction = defineAsyncComponent(() => import('@/components/content-blocks/Instruction.vue'));
() => import("@/components/content-blocks/DocumentBlock.vue") const BookmarkActions = defineAsyncComponent(() => import('@/components/notes/BookmarkActions.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 = type ContentComponentType =
| typeof TextBlock | typeof TextBlock
@ -119,8 +87,9 @@ const props = withDefaults(defineProps<Props>(), {
component: () => ({}), component: () => ({}),
parent: () => ({}), parent: () => ({}),
bookmarks: () => [], bookmarks: () => [],
highlights: () => [],
notes: () => [], notes: () => [],
root: "", root: '',
editMode: false, editMode: false,
}); });
@ -149,44 +118,39 @@ const components: Record<string, ContentComponentType> = {
assignment: Assignment, assignment: Assignment,
}; };
const contentComponentDiv = ref<HTMLElement | null>(null);
const componentType = computed(() => { 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( 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 note = computed(() => {
const bookmark = const bookmark = props.bookmarks && props.bookmarks.find((bookmark) => bookmark.uuid === props.component.id);
props.bookmarks &&
props.bookmarks.find((bookmark) => bookmark.uuid === props.component.id);
return bookmark && bookmark.note; return bookmark && bookmark.note;
}); });
const showBookmarkActions = computed( 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(() => { const componentClass = computed(() => {
let classes = [ let classes = ['content-component', `content-component--${props.component.type}`];
"content-component",
`content-component--${props.component.type}`,
];
if (bookmarked.value) { if (bookmarked.value) {
classes.push("content-component--bookmarked"); classes.push('content-component--bookmarked');
} }
return classes; return classes;
}); });
const addNote = (id: string) => { 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 ? props.parent.__typename
: "ContentBlockNode"; : 'ContentBlockNode';
store.dispatch("addNote", { store.dispatch('addNote', {
content: id, content: id,
type, type,
block: props.root, block: props.root,
@ -194,16 +158,15 @@ const addNote = (id: string) => {
}; };
const editNote = () => { const editNote = () => {
store.dispatch("editNote", note); store.dispatch('editNote', note);
}; };
const { mutation, variables, update, optimisticResponse } = const { mutation, variables, update, optimisticResponse } = constructContentComponentBookmarkMutation(
constructContentComponentBookmarkMutation( props.component.id,
props.component.id, bookmarked.value,
bookmarked.value, props.parent,
props.parent, props.root
props.root );
);
const { mutate: mutateBookmarkContent } = useMutation(mutation, { const { mutate: mutateBookmarkContent } = useMutation(mutation, {
update, update,
optimisticResponse, optimisticResponse,
@ -219,10 +182,35 @@ const bookmarkContent = (bookmarked: boolean) => {
console.log(newVars); console.log(newVars);
mutateBookmarkContent(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> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@import "styles/helpers"; @import 'styles/helpers';
.content-component { .content-component {
position: relative; 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? // 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? // 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 contentBlock: string; // the id
contentIndex: number; // which content of the .contents array do I come from? contentIndex: number; // which content of the .contents array do I come from?
contentUUID: string; // the wagtail UUID of the content element contentUUID: string; // the wagtail UUID of the content element
@ -22,6 +22,7 @@ interface Highlight {
startPosition: number; // start of the selection startPosition: number; // start of the selection
selectionLength: number; // length of the selection selectionLength: number; // length of the selection
text: string; // text that was actually selected text: string; // text that was actually selected
color?: string;
} }
const TOP_TRESHOLD = 0; const TOP_TRESHOLD = 0;
@ -86,78 +87,84 @@ const findPositionInParent = (element: HTMLElement, className: string = 'content
return children.indexOf(element); return children.indexOf(element);
}; };
export const getSelectionHandler = (el: HTMLElement, contentBlock: ContentBlockNode) => (_e: Event) => { export const getSelectionHandler =
const rect = el.getBoundingClientRect(); (el: HTMLElement, contentBlock: ContentBlockNode, onUpdateHighlight: (highlight: any) => void = () => {}) =>
if (isInsideViewport(rect.top) || isInsideViewport(rect.bottom)) { (_e: Event) => {
// the listener only does something if the `el` is visible in the viewport, to save resources 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(); const selection = rangy.getSelection();
if (selection.rangeCount && !selection.isCollapsed) { if (selection.rangeCount && !selection.isCollapsed) {
const range = selection.getRangeAt(0); const range = selection.getRangeAt(0);
if (range.intersectsNode(el)) { if (range.intersectsNode(el)) {
// the selection is inside our container, so we can do something with it // the selection is inside our container, so we can do something with it
const { startContainer, endContainer } = range; const { startContainer, endContainer } = range;
const paragraph = findClosestAncestorWithTag(startContainer, 'p'); const paragraph = findClosestAncestorWithTag(startContainer, 'p');
const endAncestor = findClosestAncestorWithTag(endContainer, 'p'); const endAncestor = findClosestAncestorWithTag(endContainer, 'p');
if (paragraph && paragraph === endAncestor) { if (!paragraph) {
// todo: rename this variable, `parent` is not correct anymore throw Error('This should not be happening, but there was no paragraph');
const contentComponent = findClosestAncestorWithClass(startContainer, 'content-component'); }
if (contentComponent) { if (paragraph === endAncestor) {
// our selection is wholly inside the container node, we continue with it const contentComponent = findClosestAncestorWithClass(startContainer, 'content-component');
const position = findPositionInParent(contentComponent); if (contentComponent) {
const siblings = Array.from(paragraph.parentElement?.children || []); // our selection is wholly inside the container node, we continue with it
const positionInTextBlock = siblings.indexOf(paragraph); 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( const numOfParagraphs = Array.from(contentComponent.children[1].children).filter(
(child) => child.nodeName.toLowerCase() === 'p' (child) => child.nodeName.toLowerCase() === 'p'
).length; ).length;
const { start, end } = range.toCharacterRange(paragraph); const { start, end } = range.toCharacterRange(paragraph);
const uuid = contentComponent.dataset.uuid || ''; const uuid = contentComponent.dataset.uuid || '';
const highlightedText: Highlight = { const highlightedText: Highlight = {
contentBlock: contentBlock.id, contentBlock: contentBlock.id,
contentIndex: position, contentIndex: position,
contentUUID: uuid, contentUUID: uuid,
startPosition: start, startPosition: start,
paragraphIndex: positionInTextBlock, paragraphIndex: positionInTextBlock,
selectionLength: end - start, selectionLength: end - start,
text: range.toString(), text: range.toString(),
}; };
console.log(highlightedText); console.log(highlightedText);
const positionOfContentBlock = el.getBoundingClientRect(); const positionOfContentBlock = el.getBoundingClientRect();
const positionOfSelection = paragraph?.getBoundingClientRect(); const positionOfSelection = paragraph?.getBoundingClientRect();
const offsetTop = positionOfSelection.top - positionOfContentBlock.top; const offsetTop = positionOfSelection.top - positionOfContentBlock.top;
popover popover
.show({ .show({
wrapper: el, wrapper: el,
offsetTop, offsetTop,
onChooseColor: (color: string) => { onChooseColor: (color: string) => {
console.log('chosenColor', color); console.log('chosenColor', color);
// const newHighlight: Highlight = { highlightedText.color = color;
// contentBlock: contentBlock.id, // const newHighlight: Highlight = {
// contentIndex: // contentBlock: contentBlock.id,
// // contentIndex:
// }; //
}, // };
}) },
.then(() => { })
console.log('confirmed'); .then(() => {
}) console.log('confirmed here');
.catch(() => { onUpdateHighlight();
console.log('canceled'); })
}); .catch(() => {
// todo: how do we save this? maybe we need to move away from the directive and do this inside the mounted console.log('canceled');
// and unmounted hooks of the ContentBlock });
// 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[]) => { const highlight = (element: HTMLElement, ranges: Mark.Range[]) => {
// todo: make the ContentComponent / TextBlock component handle the highlighting // 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 // code inside this setTimeout would run too early and yield no elements
setTimeout(() => { setTimeout(() => {
const elements = el.querySelectorAll<HTMLElement>('.content-component'); const elements = el.querySelectorAll<HTMLElement>('.content-component');
// console.log(el);
// console.log(elements);
for (const element of elements) { for (const element of elements) {
// console.log(element);
// console.log(typeof element);
highlight(element, demoRanges); highlight(element, demoRanges);
} }
// highlight(el, demoRanges); // highlight(el, demoRanges);

View File

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