Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions frontend/scenarios/comment_fragment.feature
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,15 @@ Scénario: de texte
[plusieurs personnes se présentent à moi. Ayant identifié que je suis nouveau, elles me souhaitent la bienvenue]
"""

Scénario: de texte formaté en italique

Soit "Treignes, le 8 septembre 2012 (Christophe Lejeune)" le document principal
Et un document dont je suis l'auteur affiché comme glose et contenant :
"""
[*plusieurs personnes*]
Mon commentaire sur ce passage
"""
Et une session active avec mon compte
Alors la glose contient "plusieurs personnes"
Et la citation contient du texte en italique
84 changes: 84 additions & 0 deletions frontend/src/components/CrossElementHighlighter.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { useRef, useEffect } from 'react';

function CrossElementHighlighter({ children, highlightText }) {
const containerRef = useRef(null);

useEffect(() => {
if (!containerRef.current) return;

const highlights = containerRef.current.querySelectorAll('mark');
highlights.forEach(mark => {
const parent = mark.parentNode;
while (mark.firstChild) {
parent.insertBefore(mark.firstChild, mark);
}
parent.removeChild(mark);
parent.normalize();
});

if (!highlightText) return;

const textNodes = [];
const walker = document.createTreeWalker(
containerRef.current,
NodeFilter.SHOW_TEXT,
{
acceptNode: (node) => {
if (node.textContent.trim() === '') return NodeFilter.FILTER_REJECT;
if (node.parentElement?.tagName === 'MARK') return NodeFilter.FILTER_REJECT;
return NodeFilter.FILTER_ACCEPT;
}
}
);
let node = walker.nextNode();
while (node) {
textNodes.push(node);
node = walker.nextNode();
}

let fullText = '';
const nodeMap = [];
textNodes.forEach(node => {
const start = fullText.length;
const text = node.textContent;
fullText += text;
nodeMap.push({ node, start, end: fullText.length, text });
});

const searchIndex = fullText.toLowerCase().indexOf(highlightText.toLowerCase());
if (searchIndex === -1) return;

const searchEnd = searchIndex + highlightText.length;
const affectedNodes = nodeMap.filter(item =>
(item.start < searchEnd && item.end > searchIndex)
);

affectedNodes.forEach((item, index) => {
const isFirst = index === 0;
const isLast = index === affectedNodes.length - 1;
const startOffset = isFirst ? searchIndex - item.start : 0;
const endOffset = isLast ? searchEnd - item.start : item.text.length;

const text = item.node.textContent;
const fragment = document.createDocumentFragment();

if (startOffset > 0) {
fragment.appendChild(document.createTextNode(text.substring(0, startOffset)));
}

const mark = document.createElement('mark');
mark.textContent = text.substring(startOffset, endOffset);
fragment.appendChild(mark);

if (endOffset < text.length) {
fragment.appendChild(document.createTextNode(text.substring(endOffset)));
}

item.node.parentNode.replaceChild(fragment, item.node);
});
}, [highlightText]);

return <div ref={containerRef}>{children}</div>;
}

export default CrossElementHighlighter;
84 changes: 64 additions & 20 deletions frontend/src/components/FormattedText.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,29 +10,73 @@ function FormattedText({children, setHighlightedText, selectable, setSelectedTex

const handleMouseUp = () => {
if (selectable) {
let text = window.getSelection().toString();
setSelectedText(text);
setHighlightedText(text);
const selection = window.getSelection();
let plainText = selection.toString();
if (plainText.trim()) {
const markdownText = extractMarkdownFromSelection(selection);
setSelectedText(markdownText);
setHighlightedText(plainText);
}
}
};

return (<>
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkDefinitionList, remarkUnwrapImages]}
components={{
img: (x) => embedVideo(x) || CroppedImage(x),
p: (x) => VideoComment(x)
|| FragmentComment({...x, setHighlightedText})
|| <p onMouseUp={handleMouseUp}>{x.children}</p>,
a: ({children, href}) => <a href={href}>{children}</a>
}}
remarkRehypeOptions={{
handlers: defListHastHandlers
}}
>
{children}
</ReactMarkdown>
</>);
return (
<div onMouseUp={handleMouseUp}>
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkDefinitionList, remarkUnwrapImages]}
components={{
img: (x) => embedVideo(x) || CroppedImage(x),
p: (x) => VideoComment(x)
|| FragmentComment({...x, setHighlightedText})
|| <p>{x.children}</p>,
a: ({children, href}) => <a href={href}>{children}</a>
}}
remarkRehypeOptions={{
handlers: defListHastHandlers
}}
>
{children}
</ReactMarkdown>
</div>
);
}

function extractMarkdownFromSelection(selection) {
if (selection.rangeCount === 0) return selection.toString();
return processNode(selection.getRangeAt(0).cloneContents());
}

function getMarkdownMarkers(element) {
if (!element.tagName) return null;
const tag = element.tagName;
if (tag === 'EM' || tag === 'I') return { open: '*', close: '*' };
if (tag === 'STRONG' || tag === 'B') return { open: '**', close: '**' };
if (tag === 'CODE') return { open: '`', close: '`' };
if (tag === 'DEL' || tag === 'S') return { open: '~~', close: '~~' };
return null;
}

function processNode(node) {
if (node.nodeType === Node.TEXT_NODE) return node.textContent;

if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE || node.nodeType === Node.ELEMENT_NODE) {
const markers = node.nodeType === Node.ELEMENT_NODE ? getMarkdownMarkers(node) : null;

let content = '';
for (const child of node.childNodes) {
content += processNode(child);
}

if (markers) {
const trimmedContent = content.trim();
const leadingSpaces = content.match(/^\s*/)[0];
const trailingSpaces = content.match(/\s*$/)[0];
return leadingSpaces + markers.open + trimmedContent + markers.close + trailingSpaces;
}
return content;
}

return '';
}

function getId(text) {
Expand Down
118 changes: 93 additions & 25 deletions frontend/src/components/FragmentComment.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,37 +3,30 @@ import '../styles/FragmentComment.css';
function FragmentComment({ children, setHighlightedText }) {
try {
children = (children instanceof Array) ? children : [children];
const textContent = getTextContent(children);
const citationRegex = /^\[.*\]\s*\n(.*)$/m;
if (citationRegex.test(children[0])) {
let [citation, comment] = children[0].split(/\n/);
citation = citation.replace(/[[\]]/g, '');
const commentParts = [
comment,
...children.slice(1)
].map((part, index) => (
<span key={index}>
{part}
</span>

if (citationRegex.test(textContent)) {
const { citationElements, commentElements, plainCitation } = splitCitationAndComment(children);

const commentParts = commentElements.map((part, index) => (
<span key={index}>{part}</span>
));

return <p
onMouseEnter={
e => {
e.preventDefault();
e.stopPropagation();
setHighlightedText(citation);
}
}
onMouseLeave={
e => {
e.preventDefault();
e.stopPropagation();
setHighlightedText('');
}
}
onMouseEnter={e => {
e.preventDefault();
e.stopPropagation();
setHighlightedText(plainCitation);
}}
onMouseLeave={e => {
e.preventDefault();
e.stopPropagation();
setHighlightedText('');
}}
className="fragment"
>
<span className="citation">{citation}</span>
<span className="citation">{citationElements}</span>
{commentParts}
</p>;
}
Expand All @@ -42,4 +35,79 @@ function FragmentComment({ children, setHighlightedText }) {
}
}

function getTextContent(element) {
if (typeof element === 'string') return element;
if (Array.isArray(element)) return element.map(getTextContent).join('');
if (element?.props?.children) return getTextContent(element.props.children);
return '';
}

function splitCitationAndComment(children) {
const citationElements = [];
const commentElements = [];
let mode = 'before';
let plainCitation = '';

for (const child of children) {
const text = getTextContent(child);

if (mode === 'before') {
if (text.includes('[')) {
mode = 'citation';
if (typeof child === 'string') {
const afterBracket = child.split('[')[1];
if (afterBracket) {
if (afterBracket.includes(']')) {
const betweenBrackets = afterBracket.split(']')[0];
citationElements.push(betweenBrackets);
plainCitation += betweenBrackets;
const afterClosing = afterBracket.split(']').slice(1).join(']');
if (afterClosing) {
const commentText = afterClosing.replace(/^\n/, '');
if (commentText) commentElements.push(commentText);
}
mode = 'comment';
} else {
citationElements.push(afterBracket);
plainCitation += afterBracket;
}
}
}
}
continue;
}

if (mode === 'citation') {
if (text.includes(']')) {
mode = 'comment';
if (typeof child === 'string') {
const beforeBracket = child.split(']')[0];
if (beforeBracket) {
citationElements.push(beforeBracket);
plainCitation += beforeBracket;
}
const afterBracket = child.split(']').slice(1).join(']');
if (afterBracket) {
const commentText = afterBracket.replace(/^\n/, '');
if (commentText) commentElements.push(commentText);
}
} else {
citationElements.push(child);
plainCitation += text;
}
} else {
citationElements.push(child);
plainCitation += text;
}
continue;
}

if (mode === 'comment') {
commentElements.push(child);
}
}

return { citationElements, commentElements, plainCitation: plainCitation.trim() };
}

export default FragmentComment;
6 changes: 3 additions & 3 deletions frontend/src/components/Passage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useState } from 'react';
import Container from 'react-bootstrap/Container';
import Row from 'react-bootstrap/Row';
import Col from 'react-bootstrap/Col';
import { Marker } from 'react-mark.js';
import CrossElementHighlighter from './CrossElementHighlighter';
import FormattedText from './FormattedText';
import EditableText from '../components/EditableText';
import DiscreeteDropdown from './DiscreeteDropdown';
Expand Down Expand Up @@ -73,11 +73,11 @@ function PassageSource({children, isComposite, highlightedText, setHighlightedTe

function SelectableFormattedText({children, highlightedText, setHighlightedText, setSelectedText}) {
return (
<Marker mark={highlightedText} options={({separateWordSearch: false})}>
<CrossElementHighlighter highlightText={highlightedText}>
<FormattedText selectable="true" {...{setSelectedText, setHighlightedText}}>
{children}
</FormattedText>
</Marker>
</CrossElementHighlighter>
);
}

Expand Down
6 changes: 6 additions & 0 deletions frontend/src/styles/FragmentComment.css
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,9 @@
font-weight: bold;
}

/* Ensure all inline elements within citation inherit the bold and background */
.fragment .citation * {
background-color: inherit;
font-weight: inherit;
}

4 changes: 4 additions & 0 deletions frontend/tests/outcome.js
Original file line number Diff line number Diff line change
Expand Up @@ -202,3 +202,7 @@ Alors("la colonne {int} contient {string}", (column, text) => {
cy.contains(`.lectern .main .col .col:nth-child(${column})`, text);
});

Alors("la citation contient du texte en italique", () => {
cy.get('.fragment .citation em').should('exist');
});