import * as rangy from 'rangy'; import 'rangy/lib/rangy-textrange'; import log from 'loglevel'; import { AddHighlightArgument, AddContentHighlightArgument, HighlightableNode, HighlightNode, } from '@/__generated__/graphql'; import popover from '@/helpers/popover'; import { doCreateContentHighlight, doCreateHighlight, doDeleteHighlight, doUpdateHighlight } from '@/graphql/mutations'; import { StoreObject } from '@apollo/client/cache'; import { DocumentNode } from 'graphql'; import Mark from 'mark.js'; import { graphql } from '@/__generated__'; import highlightSidebar from './highlight-sidebar'; // todo: we need to get the following information for a highlight: // Which ContentBlock? => contentBlock.id // Which ContentComponent inside the ContentBlock? => Index of contentBlock.contents // Which paragraph inside the Element // 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? export interface Highlight { page: string; // the id contentIndex?: number; // which content of the .contents array do I come from? contentUuid?: string; // the wagtail UUID of the content element paragraphIndex: number; // which paragraph inside the textBlock do I come from? 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; const BOTTOM_TRESHOLD = window.innerHeight || document.documentElement.clientHeight; const isInsideViewport = (position: number) => { return position >= TOP_TRESHOLD && position <= BOTTOM_TRESHOLD; }; const isVisibleInViewport = (rect: DOMRect) => { return ( isInsideViewport(rect.top) || // top of element is inside viewport isInsideViewport(rect.bottom) || // bottom of element is inside viewport (rect.top <= TOP_TRESHOLD && rect.bottom >= BOTTOM_TRESHOLD) // element spans whole viewport, starts before and ends after ); }; const findClosestAncestorWithTag = (node: Node, tags: string | string[]): HTMLElement | null => { let tagList = tags; if (!Array.isArray(tagList)) { tagList = [tagList]; } let currentNode: Node | null = node; while ( currentNode && !tagList.includes(currentNode.nodeName.toLowerCase()) && currentNode.nodeName.toLowerCase() !== 'body' ) { currentNode = currentNode.parentNode; } if (currentNode && currentNode.nodeName.toLowerCase() !== 'body') { return currentNode as HTMLElement; } return null; }; const findClosestAncestorWithClass = (node: Node, className: string): HTMLElement | null => { let currentNode: Node | null = node; while ( currentNode && // current node exists // current node is not an HTMLElement, so probably a TextNode // or, current node does not contain the class (!(currentNode instanceof HTMLElement) || !currentNode.classList.contains(className)) && // we have not arrived at the top yet currentNode.nodeName.toLowerCase() !== 'body' ) { // so, we continue looking currentNode = currentNode.parentNode; } if (currentNode instanceof HTMLElement && currentNode.classList.contains(className)) { // make sure we're not up at the body element return currentNode as HTMLElement; } return null; }; const findPositionInParent = (element: HTMLElement, className: string = 'content-component'): number => { /* * currently, a content block has the structure * div.content-block__container * div.content-block * div.content-block__visibility * div.content-block__title * div.content-component * div.content-component * [...] * div.content-component * * We only care about the .content-component divs, not the others * */ if (!element.parentElement) { log.error('element has no parent', element); return -1; // element has no parent, probably somehow ended up at the top. But this should probably never happen in our application } const children = Array.from(element.parentElement.children).filter((child) => child.classList.contains(className)); return children.indexOf(element); }; const getParentElement = (element: HTMLElement): HTMLElement | null => { if (element.tagName.toLowerCase() === 'li') { return element.parentElement?.parentElement || null; } else { return element.parentElement || null; } }; const getSiblings = (element: HTMLElement): Element[] => { const parent = getParentElement(element); if (!parent) { throw Error('element has no parent, this should not be possible'); } let children: Element[] = []; for (const child of parent.children) { if (child.tagName.toLowerCase() === 'ul') { children = [...children, ...child.children]; } else { children = [...children, child]; } } return children; }; export interface SelectionHandlerOptions { el: HTMLElement; page: HighlightableNode; onChangeColor?: (highlight: any) => void; onCreateNote?: (highlight: any) => void; parentSelector?: string; } export type SelectionHandlerType = (event: Event) => void; export const getSelectionHandler = ({ el, page, onChangeColor, onCreateNote, parentSelector }: SelectionHandlerOptions): SelectionHandlerType => // (el: HTMLElement, contentBlock: ContentBlockNode, onUpdateHighlight: (highlight: any) => void = () => {}) => (_e: Event) => { const rect = el.getBoundingClientRect(); if (isVisibleInViewport(rect)) { // 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 const selection = rangy.getSelection(); if (selection.rangeCount && !selection.isCollapsed) { // ⬆️ sat least one selection exists and is not of length 0 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; // todo: handle case with paragraphs and list items const startAncestor = findClosestAncestorWithTag(startContainer, ['p', 'li']); // const endAncestor = findClosestAncestorWithTag(endContainer, 'p'); if (!startAncestor) { // there was no ancestor found, but this can be the case when there's a subtitle or something similar // selected return; } // should have the same tag as the start item const endAncestor = findClosestAncestorWithTag(endContainer, startAncestor.tagName.toLowerCase()); if (startAncestor === endAncestor) { const selector = parentSelector ? parentSelector : 'content-component'; const contentComponent = findClosestAncestorWithClass(startContainer, selector); if (contentComponent) { if (contentComponent.classList.contains('content-component--solution')) { // Lorenz: I added this to prevent highlighting of the solution. It ain't pretty, but it works return; } // our selection is wholly inside the container node, we continue with it const position = findPositionInParent(contentComponent); const uuid = contentComponent.dataset.uuid || ''; // const siblings = Array.from(startAncestor.parentElement?.children || []); const siblings = getSiblings(startAncestor); const positionInTextBlock = siblings.indexOf(startAncestor); const { start, end } = range.toCharacterRange(startAncestor); const highlightedText: Highlight = { page: page.id, startPosition: start, paragraphIndex: positionInTextBlock, selectionLength: end - start, text: range.toString(), }; if (uuid > '') { highlightedText.contentIndex = position; highlightedText.contentUuid = uuid; } const positionOfContentBlock = el.getBoundingClientRect(); const positionOfSelection = startAncestor?.getBoundingClientRect(); const offsetTop = positionOfSelection.top - positionOfContentBlock.top; const onChooseColor = (color: string) => { highlightedText.color = color; if (onChangeColor) { onChangeColor(highlightedText); } }; const onNote = () => { highlightedText.color = 'alpha'; if (onCreateNote) { onCreateNote(highlightedText); } }; popover.show({ wrapper: el, offsetTop, onChooseColor, onNote, }); } } } } } }; export const replacePopover = (highlight: HighlightNode) => { // we need to replace the Popover after the Highlight is created // todo: is there a cleaner way than to look for the element and click it? const mark = document.querySelector(`mark[data-id="${highlight.id}"]`); if (mark) { mark.click(); } }; interface CreateHighlightOptions { fragment: DocumentNode; fragmentName: string; cacheSignature: StoreObject; isContentHighlight: boolean; // if it is, then we need other arguments } // todo: convert to async? export const createHighlightCurry = ({ fragment, fragmentName, cacheSignature, isContentHighlight }: CreateHighlightOptions) => async (highlight: AddHighlightArgument | AddContentHighlightArgument) => { const createHighlightFunction = isContentHighlight ? doCreateContentHighlight : doCreateHighlight; const { data } = await createHighlightFunction( { input: { highlight, }, }, { update: (cache, { data }) => { const id = cache.identify(cacheSignature); log.debug(id); const fragmentWithHighlights = cache.readFragment({ id, fragment, fragmentName, }); log.debug(fragmentWithHighlights); const highlight = isContentHighlight ? data?.addContentHighlight?.highlight : data?.addHighlight?.highlight; log.debug(highlight); if (highlight) { cache.writeFragment({ id, fragment, fragmentName, data: { ...fragmentWithHighlights, highlights: [ ...(fragmentWithHighlights?.highlights.filter((h) => h.id !== highlight.id) || []), highlight, ], }, }); } }, } ); const highlightResult = isContentHighlight ? data?.addContentHighlight?.highlight : data?.addHighlight?.highlight; if (highlightResult) { replacePopover(highlightResult); } return highlightResult; }; const onUpdateTextCurry = (highlight: HighlightNode) => (text: string) => // curry function for updating the highlight note doUpdateHighlight({ input: { note: text, id: highlight.id, }, }); interface ClickListenerCurryOptions { wrapper: HTMLElement; // the element the popover will be the direct child of, which has relative positioning parent: HTMLElement; // the element (paragraph/list) the highlight is inside highlight: HighlightNode; } const markClickListenerCurry = ({ wrapper, parent, highlight }: ClickListenerCurryOptions) => () => { if (wrapper) { const onChooseColor = async (color: string) => { try { const { data: { updateHighlight: { highlight: newHighlight }, }, } = await doUpdateHighlight({ input: { color: color, id: highlight.id, }, }); // we need to refresh the sidebar highlightSidebar.open({ highlight: newHighlight, onUpdateText: onUpdateTextCurry(newHighlight), }); } catch (error) { console.error('Error updating highlight:', error); } }; const top = parent.getBoundingClientRect().top - wrapper.getBoundingClientRect().top; popover.show({ wrapper: wrapper, offsetTop: top, onChooseColor, onDelete: deleteHighlightCurry(highlight), }); } highlightSidebar.open({ highlight, onUpdateText: onUpdateTextCurry(highlight), }); }; export const markHighlight = (highlight: HighlightNode, element: HTMLElement, popoverElement: HTMLElement) => { const instance = new Mark(element); const ranges: Mark.Range[] = [ { start: highlight.startPosition, length: highlight.selectionLength, }, ]; const className = `highlight highlight--${highlight.color}`; instance.markRanges(ranges, { className, // createMark and add Event Listener each: (newMark: HTMLElement) => { newMark.dataset.id = highlight.id; newMark.dataset.cy = 'highlight-mark'; newMark.addEventListener( 'click', markClickListenerCurry({ wrapper: popoverElement, parent: element, highlight, }) ); }, }); }; // type them out, so the static codegen can do its work const fragments = { InstrumentNode: graphql(` fragment InstrumentHighlightsWithIdOnlyFragment on InstrumentNode { highlights { id } } `), ChapterNode: graphql(` fragment ChapterHighlightsWithIdOnlyFragment on ChapterNode { highlights { id } } `), ModuleNode: graphql(` fragment ModuleHighlightsWithIdOnlyFragment on ModuleNode { highlights { id } } `), ContentBlockNode: graphql(` fragment ContentBlockHighlightsWithIdOnlyFragment on ContentBlockNode { highlights { id } } `), }; const getCacheProperties = (cache, page, typename, identifier) => { log.debug(`page is a ${typename}, remove highlights`); const fragment = fragments[typename]; const id = cache.identify({ __typename: typename, [identifier]: page[identifier], }); log.debug(`id found: ${id}`); log.debug(`fragment: ${fragment}`); log.debug(fragment); return { fragment, id }; }; const cacheIdentifiers = { InstrumentNode: 'slug', ModuleNode: 'slug', ChapterNode: 'id', ContentBlockNode: 'id', }; const getFragment = (cache, page: HighlightableNode) => { const typename = page.__typename as string; const identifier = cacheIdentifiers[typename]; const { fragment, id } = getCacheProperties(cache, page, typename, identifier); const foundFragment = cache.readFragment({ fragment, id, }); return { fragment, id, foundFragment }; }; export const deleteHighlightCurry = (highlight: HighlightNode) => () => { doDeleteHighlight( { input: { id: highlight.id, }, }, { update: ( cache, { data: { deleteHighlight: { success }, }, } ) => { if (success) { log.debug('delete successful'); const page = highlight.page; log.debug(page.__typename); const { fragment, id, foundFragment } = getFragment(cache, page); log.debug(foundFragment, id, fragment); if (foundFragment) { const data = { ...foundFragment, highlights: foundFragment.highlights.filter((h) => h.id !== highlight.id), }; cache.writeFragment({ fragment, id, data, }); } } }, } ); };