diff --git a/frontend/scenarios/comment_fragment.feature b/frontend/scenarios/comment_fragment.feature index d404f9b7..be34daf6 100644 --- a/frontend/scenarios/comment_fragment.feature +++ b/frontend/scenarios/comment_fragment.feature @@ -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 diff --git a/frontend/src/components/CrossElementHighlighter.jsx b/frontend/src/components/CrossElementHighlighter.jsx new file mode 100644 index 00000000..6f88e8cf --- /dev/null +++ b/frontend/src/components/CrossElementHighlighter.jsx @@ -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
{children}
; +} + +export default CrossElementHighlighter; \ No newline at end of file diff --git a/frontend/src/components/FormattedText.jsx b/frontend/src/components/FormattedText.jsx index 3b89be66..08ad07fd 100644 --- a/frontend/src/components/FormattedText.jsx +++ b/frontend/src/components/FormattedText.jsx @@ -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 (<> - embedVideo(x) || CroppedImage(x), - p: (x) => VideoComment(x) - || FragmentComment({...x, setHighlightedText}) - ||

{x.children}

, - a: ({children, href}) => {children} - }} - remarkRehypeOptions={{ - handlers: defListHastHandlers - }} - > - {children} -
- ); + return ( +
+ embedVideo(x) || CroppedImage(x), + p: (x) => VideoComment(x) + || FragmentComment({...x, setHighlightedText}) + ||

{x.children}

, + a: ({children, href}) => {children} + }} + remarkRehypeOptions={{ + handlers: defListHastHandlers + }} + > + {children} +
+
+ ); +} + +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) { diff --git a/frontend/src/components/FragmentComment.jsx b/frontend/src/components/FragmentComment.jsx index 305427d9..f659b78b 100644 --- a/frontend/src/components/FragmentComment.jsx +++ b/frontend/src/components/FragmentComment.jsx @@ -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) => ( - - {part} - + + if (citationRegex.test(textContent)) { + const { citationElements, commentElements, plainCitation } = splitCitationAndComment(children); + + const commentParts = commentElements.map((part, index) => ( + {part} )); return

{ - 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" > - {citation} + {citationElements} {commentParts}

; } @@ -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; diff --git a/frontend/src/components/Passage.jsx b/frontend/src/components/Passage.jsx index 7001609e..faf7edfb 100644 --- a/frontend/src/components/Passage.jsx +++ b/frontend/src/components/Passage.jsx @@ -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'; @@ -73,11 +73,11 @@ function PassageSource({children, isComposite, highlightedText, setHighlightedTe function SelectableFormattedText({children, highlightedText, setHighlightedText, setSelectedText}) { return ( - + {children} - + ); } diff --git a/frontend/src/styles/FragmentComment.css b/frontend/src/styles/FragmentComment.css index 22a1b4a8..6710997c 100644 --- a/frontend/src/styles/FragmentComment.css +++ b/frontend/src/styles/FragmentComment.css @@ -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; +} + diff --git a/frontend/tests/outcome.js b/frontend/tests/outcome.js index cf23f917..0326ecfb 100644 --- a/frontend/tests/outcome.js +++ b/frontend/tests/outcome.js @@ -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'); +}); +