skillbox/client/src/helpers/highlight.ts

478 lines
16 KiB
TypeScript

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,
});
}
}
},
}
);
};