478 lines
16 KiB
TypeScript
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,
|
|
});
|
|
}
|
|
}
|
|
},
|
|
}
|
|
);
|
|
};
|