From f4ef2b7ecde6695f8753c3440f0564e7f61b9f58 Mon Sep 17 00:00:00 2001 From: riccio82 Date: Mon, 16 Mar 2026 12:01:32 +0100 Subject: [PATCH 001/116] Create page context review --- .../Views/ContextReviewController.php | 49 +++++++++++++++++++ lib/Routes/view_routes.php | 1 + lib/View/templates/_context_review.html | 34 +++++++++++++ .../components/pages/ContextReviewPage.scss | 6 +++ public/js/pages/ContextReview.js | 24 +++++++++ webpack.config.js | 17 +++++++ 6 files changed, 131 insertions(+) create mode 100644 lib/Controller/Views/ContextReviewController.php create mode 100644 lib/View/templates/_context_review.html create mode 100644 public/css/sass/components/pages/ContextReviewPage.scss create mode 100644 public/js/pages/ContextReview.js diff --git a/lib/Controller/Views/ContextReviewController.php b/lib/Controller/Views/ContextReviewController.php new file mode 100644 index 0000000000..5827d1a894 --- /dev/null +++ b/lib/Controller/Views/ContextReviewController.php @@ -0,0 +1,49 @@ +appendValidator(new ViewLoginRedirectValidator($this)); + } + + /** + * @throws Exception + */ + public function renderView(): void + { + $request = $this->validateTheRequest(); + + $this->setView('context_review.html', [ + 'id_project' => $request['id_project'], + 'password' => $request['password'], + ]); + $this->render(); + } + + /** + * @return false|array|null + */ + protected function validateTheRequest(): false|array|null + { + $filterArgs = [ + 'id_project' => ['filter' => FILTER_SANITIZE_NUMBER_INT], + 'password' => [ + 'filter' => FILTER_SANITIZE_SPECIAL_CHARS, + 'flags' => FILTER_FLAG_STRIP_LOW | FILTER_FLAG_STRIP_HIGH + ] + ]; + + return filter_var_array($this->request->paramsNamed()->all(), $filterArgs); + } + +} + diff --git a/lib/Routes/view_routes.php b/lib/Routes/view_routes.php index 7d03e63668..86c927f03d 100644 --- a/lib/Routes/view_routes.php +++ b/lib/Routes/view_routes.php @@ -17,6 +17,7 @@ route('/jobanalysis/[i:pid]-[i:jid]-[:password]', 'GET', ['Controller\Views\AnalyzeController', 'renderView']); route('/revise-summary/[i:jid]-[:password]', 'GET', ['Controller\Views\QualityReportController', 'renderView']); route('/activityLog/[i:id_project]/[:password]', 'GET', ['Controller\Views\ActivityLogController', 'renderView']); +route('/context-review/[i:id_project]/[:password]', 'GET', ['Controller\Views\ContextReviewController', 'renderView']); route('/utils/xliff-to-target', 'GET', ['Controller\Views\XliffToTargetController', 'renderView']); route('/translate/[:project_name]/[:lang_pair]/[i:jid]-?[i:split]?-[:password]', 'GET', ['Controller\Views\CattoolController', 'renderView']); diff --git a/lib/View/templates/_context_review.html b/lib/View/templates/_context_review.html new file mode 100644 index 0000000000..628b22ab7b --- /dev/null +++ b/lib/View/templates/_context_review.html @@ -0,0 +1,34 @@ + + + + + Context Review - Matecat +
+ + + + + + + + + + +
+ +
+ +
+ +
+ + + + diff --git a/public/css/sass/components/pages/ContextReviewPage.scss b/public/css/sass/components/pages/ContextReviewPage.scss new file mode 100644 index 0000000000..00a929674c --- /dev/null +++ b/public/css/sass/components/pages/ContextReviewPage.scss @@ -0,0 +1,6 @@ +.context-review-container { + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} + diff --git a/public/js/pages/ContextReview.js b/public/js/pages/ContextReview.js new file mode 100644 index 0000000000..47667468a0 --- /dev/null +++ b/public/js/pages/ContextReview.js @@ -0,0 +1,24 @@ +import React, {useContext} from 'react' +import {mountPage} from './mountPage' +import {ApplicationWrapperContext} from '../components/common/ApplicationWrapper/ApplicationWrapperContext' +import {CookieConsent} from '../components/common/CookieConsent' + +const ContextReview = () => { + return ( + <> +
+

Context Review

+
+
+ +
+ + ) +} + +export default ContextReview + +mountPage({ + Component: ContextReview, + rootElement: document.getElementsByClassName('context-review__page')[0], +}) diff --git a/webpack.config.js b/webpack.config.js index 384102770a..345bdd1db5 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -273,6 +273,13 @@ const matecatConfig = async ({env}, {mode}) => { 'public/css/sass/components/pages/ActivityLogPage.scss', ), ], + contextReview: [ + path.resolve(__dirname, 'public/js/pages/ContextReview.js'), + path.resolve( + __dirname, + 'public/css/sass/components/pages/ContextReviewPage.scss', + ), + ], commonCss: [ path.resolve( __dirname, @@ -390,6 +397,16 @@ const matecatConfig = async ({env}, {mode}) => { publicPath: '/public/build/', xhtml: true, }), + new HtmlWebPackPlugin({ + filename: path.resolve(__dirname, './lib/View/context_review.html'), + template: path.resolve( + __dirname, + './lib/View/templates/_context_review.html', + ), + chunks: ['contextReview', 'allPagesPlugins'], + publicPath: '/public/build/', + xhtml: true, + }), new HtmlWebPackPlugin({ filename: path.resolve( __dirname, From 8d6da13f347c56550eee235b888a250103bea5b2 Mon Sep 17 00:00:00 2001 From: riccio82 Date: Mon, 16 Mar 2026 17:37:59 +0100 Subject: [PATCH 002/116] Context review page --- .../components/pages/ContextReviewPage.scss | 108 +- public/js/components/segments/Editarea.js | 6 + public/js/components/segments/Segment.js | 1353 ++++++++--------- .../components/segments/SegmentsContainer.js | 25 +- public/js/hooks/useContextReviewChannel.js | 44 + public/js/pages/CatTool.js | 28 +- public/js/pages/ContextReview.js | 209 ++- public/js/utils/contextReviewChannel.js | 93 ++ public/js/utils/contextReviewUtils.js | 222 +++ webpack.config.js | 9 + 10 files changed, 1363 insertions(+), 734 deletions(-) create mode 100644 public/js/hooks/useContextReviewChannel.js create mode 100644 public/js/utils/contextReviewChannel.js create mode 100644 public/js/utils/contextReviewUtils.js diff --git a/public/css/sass/components/pages/ContextReviewPage.scss b/public/css/sass/components/pages/ContextReviewPage.scss index 00a929674c..9846512773 100644 --- a/public/css/sass/components/pages/ContextReviewPage.scss +++ b/public/css/sass/components/pages/ContextReviewPage.scss @@ -1,6 +1,112 @@ +.context-review-page { + width: 100%; + max-width: unset; +} .context-review-container { - max-width: 1200px; margin: 0 auto; padding: 20px; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.context-review-panels { + display: flex; + flex: 1; + gap: 0; + overflow: hidden; +} + +.context-review-panel { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + overflow: hidden; +} + +.context-review-panel-header { + padding: 8px 16px; + font-size: 13px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #7f8c8d; + background-color: #f8f9fa; + border-bottom: 1px solid #e0e0e0; + flex-shrink: 0; +} + +.context-review-divider { + width: 1px; + background-color: #d0d0d0; + flex-shrink: 0; } +.context-review-content { + flex: 1; + overflow-y: auto; + cursor: default; + padding: 20px; + + p, + li, + td, + th, + h1, + h2, + h3, + h4 { + cursor: pointer; + transition: background-color 0.15s ease; + + &:hover { + background-color: rgba(52, 152, 219, 0.08); + border-radius: 2px; + } + } +} + +.context-review-loading { + display: flex; + align-items: center; + justify-content: center; + min-height: 300px; + font-size: 16px; + color: #7f8c8d; +} + +.context-review-error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 300px; + text-align: center; + + h2 { + color: #e74c3c; + margin-bottom: 12px; + font-size: 20px; + } + + p { + color: #7f8c8d; + font-size: 14px; + } +} + +// Highlight styles for matched text +mark.context-review-highlight { + background-color: #fef08a; + color: inherit; + padding: 1px 2px; + border-radius: 2px; + box-decoration-break: clone; +} + +mark.context-review-highlight--active { + background-color: #fb923c; + outline: 2px solid #ea580c; + outline-offset: 1px; +} diff --git a/public/js/components/segments/Editarea.js b/public/js/components/segments/Editarea.js index ab3f5ede99..a27161ea42 100644 --- a/public/js/components/segments/Editarea.js +++ b/public/js/components/segments/Editarea.js @@ -46,6 +46,7 @@ import { import {isMacOS} from '../../utils/Utils' import {removeZeroWidthSpace} from './utils/DraftMatecatUtils/tagUtils' import textUtils from '../../utils/textUtils' +import ContextReviewChannel from '../../utils/contextReviewChannel' const {hasCommandModifier, isOptionKeyCommand, isCtrlKeyCommand} = KeyBindingUtil @@ -361,6 +362,11 @@ class Editarea extends React.Component { missingTags, lxqDecodedTranslation, ) + ContextReviewChannel.sendMessage({ + type: 'updateTranslation', + sid: segment.sid, + target: plainText, + }) this.props.updateCounter( DraftMatecatUtils.getCharactersCounter( this.getTextToApplyCounter(decodedSegment), diff --git a/public/js/components/segments/Segment.js b/public/js/components/segments/Segment.js index de804cdc1c..0b3113cd5f 100644 --- a/public/js/components/segments/Segment.js +++ b/public/js/components/segments/Segment.js @@ -1,9 +1,13 @@ import {forEach, isUndefined} from 'lodash' -import {fromJS} from 'immutable' -import React from 'react' +import React, { + useState, + useRef, + useContext, + useEffect, + useCallback, +} from 'react' import {union} from 'lodash/array' import $ from 'jquery' - import SegmentCommentsContainer from './SegmentCommentsContainer' import SegmentsCommentsIcon from './SegmentsCommentsIcon' import SegmentStore from '../../stores/SegmentStore' @@ -30,140 +34,97 @@ import {ApplicationWrapperContext} from '../common/ApplicationWrapper/Applicatio import {Shortcuts} from '../../utils/shortcuts' import SearchUtils from '../header/cattol/search/searchUtils' import {SegmentQAIcon} from './SegmentQAIcon' - -class Segment extends React.Component { - static contextType = ApplicationWrapperContext - - constructor(props) { - super(props) - - this.createSegmentClasses = this.createSegmentClasses.bind(this) - this.hightlightEditarea = this.hightlightEditarea.bind(this) - this.addClass = this.addClass.bind(this) - this.removeClass = this.removeClass.bind(this) - this.setAsAutopropagated = this.setAsAutopropagated.bind(this) - this.setSegmentStatus = this.setSegmentStatus.bind(this) - this.handleChangeBulk = this.handleChangeBulk.bind(this) - this.openSegment = this.openSegment.bind(this) - this.openSegmentFromAction = this.openSegmentFromAction.bind(this) - this.checkIfCanOpenSegment = this.checkIfCanOpenSegment.bind(this) - this.handleKeyDown = this.handleKeyDown.bind(this) - this.forceUpdateSegment = this.forceUpdateSegment.bind(this) - this.clientReconnection = this.clientReconnection.bind(this) - - let readonly = SegmentUtils.isReadonlySegment(this.props.segment) - this.secondPassLocked = SegmentUtils.isSecondPassLockedSegment( - this.props.segment, +import useContextReviewChannel from '../../hooks/useContextReviewChannel' + +const SegmentComponent = ({ + segment, + segImmutable, + fid, + isReview, + guessTagActive, + sideOpen, + clientConnected, + clientId, + speechToTextActive, + files, + speech2textEnabledFn, + multiMatchLangs, + setBulkSelection, + setLastSelectedSegment, +}) => { + const {userInfo} = useContext(ApplicationWrapperContext) + + // DOM refs + const sectionRef = useRef(null) + const $sectionRef = useRef(null) + const timeoutScrollRef = useRef(null) + + // Value refs — keep latest values for stable store-listener callbacks + const segmentRef = useRef(segment) + segmentRef.current = segment + const fidRef = useRef(fid) + fidRef.current = fid + const isReviewRef = useRef(isReview) + isReviewRef.current = isReview + const clientConnectedRef = useRef(clientConnected) + clientConnectedRef.current = clientConnected + const multiMatchLangsRef = useRef(multiMatchLangs) + multiMatchLangsRef.current = multiMatchLangs + + // Derived values (recomputed each render) + const secondPassLocked = SegmentUtils.isSecondPassLockedSegment(segment) + const readonly = SegmentUtils.isReadonlySegment(segment) + const tagProjectionEnabled = + guessTagActive && + segment && + segment.status && + (segment.status.toLowerCase() === 'draft' || + segment.status.toLowerCase() === 'new') && + !DraftMatecatUtils.checkXliffTagsInText(segment.translation) && + DraftMatecatUtils.removeTagsFromText(segment.segment) !== '' + const dataAttrTagged = + tagProjectionEnabled && !segment.tagged ? 'nottagged' : 'tagged' + + // State + const [segmentClasses, setSegmentClasses] = useState([]) + const [autopropagated, setAutopropagated] = useState( + segment.autopropagated_from != 0, + ) + const [selectedTextObj, setSelectedTextObj] = useState(null) + const [, setForceUpdate] = useState(0) + + // ------------------------------------------------------------------ + // Helpers (stable callbacks that use refs to read current values) + // ------------------------------------------------------------------ + + const checkIfCanOpenSegment = useCallback(() => { + const seg = segmentRef.current + return ( + (isReviewRef.current && + !(seg.status.toUpperCase() == SEGMENTS_STATUS.NEW) && + !(seg.status.toUpperCase() == SEGMENTS_STATUS.DRAFT)) || + !isReviewRef.current ) + }, []) - this.state = { - segment_classes: [], - autopropagated: this.props.segment.autopropagated_from != 0, - unlocked: SegmentUtils.isUnlockedSegment(this.props.segment), - readonly: readonly, - inBulk: false, - tagProjectionEnabled: - this.props.guessTagActive && - this.props.segment && - this.props.segment.status && - (this.props.segment.status.toLowerCase() === 'draft' || - this.props.segment.status.toLowerCase() === 'new') && - !DraftMatecatUtils.checkXliffTagsInText( - this.props.segment.translation, - ) && - DraftMatecatUtils.removeTagsFromText(this.props.segment.segment) !== '', - selectedTextObj: null, - showActions: false, - } - this.timeoutScroll - } - - checkOpenSegmentComment() { + const checkOpenSegmentComment = useCallback(() => { + const seg = segmentRef.current if ( CommentsStore.db.getCommentsCountBySegment && - SegmentStore.getCurrentSegmentId() === this.props.segment.sid + SegmentStore.getCurrentSegmentId() === seg.sid ) { - const comments_obj = CommentsStore.db.getCommentsCountBySegment( - this.props.segment.sid, - ) + const comments_obj = CommentsStore.db.getCommentsCountBySegment(seg.sid) const panelClosed = localStorage.getItem(SegmentActions.localStorageCommentsClosed) === 'true' if (comments_obj.active > 0 && !panelClosed) { - SegmentActions.openSegmentComment(this.props.segment.sid) - SegmentActions.scrollToSegment(this.props.segment.sid) - } - } - } - - openSegment(wasOriginatedFromBrowserHistory) { - if (!this.$section.length) return - if (!this.checkIfCanOpenSegment()) { - const progress = CatToolStore.getProgress() - if (progress && progress.raw.translated === 0) { - this.alertNoTranslatedSegments() - } else { - this.alertNotTranslatedYet(this.props.segment.sid) - } - } else { - if (this.props.segment.translation?.length !== 0) { - SegmentActions.getSegmentsQa(this.props.segment) - } - - // start old cache - SegmentActions.setCurrentSegmentId(this.props.segment.sid) - - $('html').trigger('open') // used by ui.review to open tab Revise in the footer next-unapproved - - //Used by Segment Filter, Comments, Footer, Review extended - setTimeout(() => { - const segmentId = this.props.segment.original_sid - //Segment Filter - if (SegmentFilterUtils.enabled()) { - SegmentFilterUtils.setStoredState({ - lastSegmentId: segmentId, - }) - } - //Review - if (config.isReview) { - const panelClosed = - localStorage.getItem( - SegmentActions.localStorageReviewPanelClosed, - ) === 'true' - if (!panelClosed) { - SegmentActions.openIssuesPanel({sid: segmentId}, false) - } - SegmentActions.getSegmentVersionsIssues(segmentId) - } - }) - this.checkOpenSegmentComment() - - /************/ - if (this.props.clientConnected) { - SegmentActions.getGlossaryForSegment({ - sid: this.props.segment.sid, - fid: this.props.fid, - text: this.props.segment.segment, - }) + SegmentActions.openSegmentComment(seg.sid) + SegmentActions.scrollToSegment(seg.sid) } - - const hashUrl = document.location.pathname + '#' + this.props.segment.sid - if (wasOriginatedFromBrowserHistory) { - history.replaceState(null, null, hashUrl) - } else { - history.pushState(null, null, hashUrl) - } - var historyChangeStateEvent = new Event('historyChangeState') - window.dispatchEvent(historyChangeStateEvent) - - // Update document title with hash segment id - document.title = `${document.title?.split('#')[0]} #${ - this.props.segment.sid - }` } - } + }, []) - alertNotTranslatedYet = (sid) => { + const alertNotTranslatedYet = useCallback((sid) => { setTimeout(() => ModalsActions.showModalComponent(ConfirmMessageModal, { cancelText: 'Close', @@ -172,10 +133,10 @@ class Segment extends React.Component { text: 'This segment is not translated yet.
Only translated segments can be revised.', }), ) - } + }, []) - alertNoTranslatedSegments = () => { - var props = { + const alertNoTranslatedSegments = useCallback(() => { + const props = { text: 'There are no translated segments to revise in this job.', successText: 'Ok', successCallback: function () { @@ -185,698 +146,658 @@ class Segment extends React.Component { setTimeout(() => ModalsActions.showModalComponent(ConfirmMessageModal, props, 'Warning'), ) - } + }, []) - openSegmentFromAction(sid, wasOriginatedFromBrowserHistory) { - sid = sid + '' - if ( - (sid === this.props.segment.sid || - (this.props.segment.original_sid === sid && - this.props.segment.firstOfSplit)) && - !this.props.segment.opened - ) { - this.openSegment(wasOriginatedFromBrowserHistory) - } - } + const openSegment = useCallback( + (wasOriginatedFromBrowserHistory) => { + const seg = segmentRef.current - createSegmentClasses() { - let classes = [] - let splitGroup = this.props.segment.split_group || [] - let readonly = this.state.readonly - if (readonly) { - classes.push('readonly') - } + if (!$sectionRef.current || !$sectionRef.current.length) return - if ( - (SegmentUtils.isIceSegment(this.props.segment) && !readonly) || - this.secondPassLocked - ) { - if (this.props.segment.unlocked) { - classes.push('ice-unlocked') + if (!checkIfCanOpenSegment()) { + const progress = CatToolStore.getProgress() + if (progress && progress.raw.translated === 0) { + alertNoTranslatedSegments() + } else { + alertNotTranslatedYet(seg.sid) + } } else { - classes.push('readonly') - classes.push('ice-locked') - } - } + if (seg.translation?.length !== 0) { + SegmentActions.getSegmentsQa(seg) + } - if (this.props.segment.status) { - classes.push('status-' + this.props.segment.status.toLowerCase()) - } else { - classes.push('status-new') - } + SegmentActions.setCurrentSegmentId(seg.sid) + $('html').trigger('open') - if (this.props.segment.sid == splitGroup[0]) { - classes.push('splitStart') - } else if (this.props.segment.sid == splitGroup[splitGroup.length - 1]) { - classes.push('splitEnd') - } else if (splitGroup.length) { - classes.push('splitInner') - } - if (this.state.tagProjectionEnabled && !this.props.segment.tagged) { - classes.push('enableTP') - this.dataAttrTagged = 'nottagged' - } else { - this.dataAttrTagged = 'tagged' - } - if (this.props.segment.edit_area_locked) { - classes.push('editAreaLocked') - } - if (this.props.segment.inBulk) { - classes.push('segment-selected-inBulk') - } - if (this.props.segment.muted) { - classes.push('muted') - } - if (this.props.segment.opened && this.checkIfCanOpenSegment()) { - classes.push('editor') - classes.push('opened') - } - if ( - this.props.segment.modified || - this.props.segment.autopropagated_from !== 0 - ) { - classes.push('modified') - } - if (this.props.sideOpen) { - classes.push('slide-right') - } - if (this.props.segment.openSplit) { - classes.push('split-action') - } + setTimeout(() => { + const segmentId = segmentRef.current.original_sid + if (SegmentFilterUtils.enabled()) { + SegmentFilterUtils.setStoredState({lastSegmentId: segmentId}) + } + if (config.isReview) { + const panelClosed = + localStorage.getItem( + SegmentActions.localStorageReviewPanelClosed, + ) === 'true' + if (!panelClosed) { + SegmentActions.openIssuesPanel({sid: segmentId}, false) + } + SegmentActions.getSegmentVersionsIssues(segmentId) + } + }) - if (this.props.segment.selected) { - classes.push('segment-selected') - } - return classes - } + checkOpenSegmentComment() - hightlightEditarea(sid) { - if (this.props.segment.sid == sid) { - /* TODO REMOVE THIS CODE - * The segment must know about his classes - */ - let classes = this.state.segment_classes.slice() - if (classes.indexOf('modified')) { - classes.push('modified') - this.setState({ - segment_classes: classes, - }) - } - } - } + if (clientConnectedRef.current) { + SegmentActions.getGlossaryForSegment({ + sid: seg.sid, + fid: fidRef.current, + text: seg.segment, + }) + } - addClass(sid, newClass) { - if ( - this.props.segment.sid == sid || - sid === -1 || - sid.split('-')[0] == this.props.segment.sid - ) { - let classes = this.state.segment_classes.slice() - if (newClass.indexOf(' ') > 0) { - let classesSplit = newClass.split(' ') - forEach(classesSplit, function (item) { - if (classes.indexOf(item) < 0) { - classes.push(item) - } - }) - } else { - if (classes.indexOf(newClass) < 0) { - classes.push(newClass) + const hashUrl = document.location.pathname + '#' + seg.sid + if (wasOriginatedFromBrowserHistory) { + history.replaceState(null, null, hashUrl) + } else { + history.pushState(null, null, hashUrl) } - } - this.setState({ - segment_classes: classes, - }) - } - } + var historyChangeStateEvent = new Event('historyChangeState') + window.dispatchEvent(historyChangeStateEvent) - removeClass(sid, className) { + document.title = `${document.title?.split('#')[0]} #${seg.sid}` + } + }, + [ + checkIfCanOpenSegment, + alertNoTranslatedSegments, + alertNotTranslatedYet, + checkOpenSegmentComment, + ], + ) + + // Keep a ref so store listeners always call the latest version + const openSegmentRef = useRef(openSegment) + openSegmentRef.current = openSegment + + const removeSelection = useCallback(() => { + const selection = document.getSelection() if ( - this.props.segment.sid == sid || - sid === -1 || - sid.indexOf(this.props.segment.sid) !== -1 + sectionRef.current && + sectionRef.current.contains(selection.anchorNode) ) { - let classes = this.state.segment_classes.slice() - let removeFn = function (item) { - let index = classes.indexOf(item) - if (index > -1) { - classes.splice(index, 1) + selection.removeAllRanges() + } + setSelectedTextObj(null) + }, []) + + // ------------------------------------------------------------------ + // Store-listener callbacks (stable — use refs for current values) + // ------------------------------------------------------------------ + + const addClass = useCallback((sid, newClass) => { + const seg = segmentRef.current + if (seg.sid == sid || sid === -1 || sid.split('-')[0] == seg.sid) { + setSegmentClasses((prev) => { + let classes = prev.slice() + if (newClass.indexOf(' ') > 0) { + forEach(newClass.split(' '), function (item) { + if (classes.indexOf(item) < 0) classes.push(item) + }) + } else { + if (classes.indexOf(newClass) < 0) classes.push(newClass) } - } - if (className.indexOf(' ') > 0) { - let classesSplit = className.split(' ') - forEach(classesSplit, function (item) { - removeFn(item) - }) - } else { - removeFn(className) - } - this.setState({ - segment_classes: classes, + return classes }) } - } + }, []) - setAsAutopropagated(sid, propagation) { - if (this.props.segment.sid == sid) { - this.setState({ - autopropagated: propagation, + const removeClass = useCallback((sid, className) => { + const seg = segmentRef.current + if (seg.sid == sid || sid === -1 || sid.indexOf(seg.sid) !== -1) { + setSegmentClasses((prev) => { + let classes = prev.slice() + const removeFn = function (item) { + let index = classes.indexOf(item) + if (index > -1) classes.splice(index, 1) + } + if (className.indexOf(' ') > 0) { + forEach(className.split(' '), function (item) { + removeFn(item) + }) + } else { + removeFn(className) + } + return classes }) } - } - setSegmentStatus(sid, status) { - if (this.props.segment.sid == sid) { - let classes = this.state.segment_classes.slice(0) - let index = classes.findIndex(function (item) { - return item.indexOf('status-') > -1 - }) + }, []) - if (index >= 0) { - classes.splice(index, 1) - } + const setAsAutopropagated = useCallback((sid, propagation) => { + if (segmentRef.current.sid == sid) { + setAutopropagated(propagation) + } + }, []) - this.setState({ - segment_classes: classes, - status: status, + const setSegmentStatus = useCallback((sid) => { + if (segmentRef.current.sid == sid) { + setSegmentClasses((prev) => { + let classes = prev.slice(0) + let index = classes.findIndex(function (item) { + return item.indexOf('status-') > -1 + }) + if (index >= 0) classes.splice(index, 1) + return classes }) } - } - checkSegmentStatus(classes) { - if (classes.length === 0) return classes - // TODO: remove this - //To fix a problem: sometimes the section segment has two different status - let statusMatches = classes.join(' ').match(/status-/g) - if (statusMatches && statusMatches.length > 1) { - let index = classes.findIndex(function (item) { - return item.indexOf('status-new') > -1 - }) + }, []) - if (index >= 0) { - classes.splice(index, 1) + const openSegmentFromAction = useCallback( + (sid, wasOriginatedFromBrowserHistory) => { + sid = sid + '' + const seg = segmentRef.current + if ( + (sid === seg.sid || (seg.original_sid === sid && seg.firstOfSplit)) && + !seg.opened + ) { + openSegmentRef.current(wasOriginatedFromBrowserHistory) } - } - return classes - } - isSplitted() { - return !isUndefined(this.props.segment.split_group) - } + }, + [], + ) - isFirstOfSplit() { - return ( - !isUndefined(this.props.segment.split_group) && - this.props.segment.split_group.indexOf(this.props.segment.sid) === 0 - ) - } - - getTranslationIssues() { + const openRevisionPanel = useCallback((data) => { + const seg = segmentRef.current if ( - ((this.props.sideOpen && - (!this.props.segment.opened || !this.props.segment.openIssues)) || - !this.props.sideOpen) && - !this.props.segment.readonly && - (!this.isSplitted() || (this.isSplitted() && this.isFirstOfSplit())) && - this.props.segment.sid + parseInt(data.sid) === parseInt(seg.sid) && + (!SegmentUtils.isIceSegment(seg) || + (SegmentUtils.isIceSegment(seg) && seg.unlocked)) ) { - return ( - - ) - } - return null - } - - lockUnlockSegment(event) { - event.preventDefault() - event.stopPropagation() - if ( - !this.props.segment.unlocked && - SegmentUtils.isSecondPassLockedSegment(this.props.segment) - ) { - var props = { - text: 'You are about to edit a segment that has been approved in the 2nd pass review. The project owner and 2nd pass reviser will be notified.', - successText: 'Ok', - successCallback: function () { - ModalsActions.onCloseModal() - }, - } - ModalsActions.showModalComponent( - ConfirmMessageModal, - props, - 'Modify locked and approved segment ', - ) - } - SegmentActions.setSegmentLocked( - this.props.segment, - this.props.fid, - !this.props.segment.unlocked, - ) - } - - checkSegmentClasses() { - let classes = this.state.segment_classes.slice() - classes = union(classes, this.createSegmentClasses()) - classes = this.checkSegmentStatus(classes) - if (classes.indexOf('muted') > -1 && classes.indexOf('editor') > -1) { - let indexEditor = classes.indexOf('editor') - classes.splice(indexEditor, 1) - let indexOpened = classes.indexOf('opened') - classes.splice(indexOpened, 1) - } - return classes - } - - handleChangeBulk(event) { - event.stopPropagation() - if (event.shiftKey) { - this.props.setBulkSelection(this.props.segment.sid, this.props.fid) + setSelectedTextObj(data.selection) } else { - SegmentActions.toggleSegmentOnBulk(this.props.segment.sid, this.props.fid) - this.props.setLastSelectedSegment(this.props.segment.sid, this.props.fid) + setSelectedTextObj(null) } - } + }, []) - openRevisionPanel = (data) => { - if ( - parseInt(data.sid) === parseInt(this.props.segment.sid) && - (!SegmentUtils.isIceSegment(this.props.segment) || - (SegmentUtils.isIceSegment(this.props.segment) && - this.props.segment.unlocked)) - ) { - this.setState({ - selectedTextObj: data.selection, - }) - } else { - this.setState({ - selectedTextObj: null, - }) + const forceUpdateSegment = useCallback((sid) => { + if (segmentRef.current.sid === sid) { + setForceUpdate((c) => c + 1) } - } - removeSelection = () => { - var selection = document.getSelection() - if (this.section.contains(selection.anchorNode)) { - selection.removeAllRanges() - } - this.setState({ - selectedTextObj: null, - }) - } + }, []) - checkIfCanOpenSegment() { - return ( - (this.props.isReview && - !(this.props.segment.status.toUpperCase() == SEGMENTS_STATUS.NEW) && - !(this.props.segment.status.toUpperCase() == SEGMENTS_STATUS.DRAFT)) || - !this.props.isReview - ) - } - onClickEvent = () => { - if ( - this.state.readonly || - (!this.props.segment.unlocked && - SegmentUtils.isIceSegment(this.props.segment)) - ) { - SegmentActions.handleClickOnReadOnly(this.props.segment) - } else if (this.props.segment.muted) { - return - } else if (!this.props.segment.opened) { - this.openSegment() - if (this.checkIfCanOpenSegment()) - SegmentActions.setOpenSegment(this.props.segment.sid, this.props.fid) + const clientReconnection = useCallback(() => { + const seg = segmentRef.current + if (seg.opened) { + SegmentActions.getGlossaryForSegment({ + sid: seg.sid, + fid: fidRef.current, + text: seg.segment, + }) + SegmentActions.getContributions(seg.sid, multiMatchLangsRef.current) } - } + }, []) - handleKeyDown(event) { + const handleKeyDown = useCallback((event) => { + const seg = segmentRef.current if (event.code === 'Escape' && !config.targetIsCJK) { if ( - this.props.segment.opened && - !this.props.segment.openComments && - !this.props.segment.openIssues && + seg.opened && + !seg.openComments && + !seg.openIssues && !SearchUtils.searchOpen ) { - if (!this.props.segment.openSplit) { - SegmentActions.closeSegment(this.props.segment.sid) + if (!seg.openSplit) { + SegmentActions.closeSegment(seg.sid) } else { SegmentActions.closeSplitSegment() } - } else if (this.props.segment.openComments) { + } else if (seg.openComments) { SegmentActions.closeSegmentComment() - } else if (this.props.segment.openIssues) { + } else if (seg.openIssues) { SegmentActions.closeIssuesPanel() } } - } - - clientReconnection() { - if (this.props.segment.opened) { - SegmentActions.getGlossaryForSegment({ - sid: this.props.segment.sid, - fid: this.props.fid, - text: this.props.segment.segment, - }) - SegmentActions.getContributions( - this.props.segment.sid, - this.props.multiMatchLangs, - ) - } - } + }, []) - forceUpdateSegment(sid) { - if (this.props.segment.sid === sid) { - this.forceUpdate() - } - } + // ------------------------------------------------------------------ + // Effects + // ------------------------------------------------------------------ - allowHTML(string) { - return {__html: string} - } + // Mount / Unmount — register store listeners & keydown handler + useEffect(() => { + $sectionRef.current = $(sectionRef.current) - componentDidMount() { - this.$section = $(this.section) - document.addEventListener('keydown', this.handleKeyDown) - SegmentStore.addListener(SegmentConstants.ADD_SEGMENT_CLASS, this.addClass) - SegmentStore.addListener( - SegmentConstants.REMOVE_SEGMENT_CLASS, - this.removeClass, - ) + document.addEventListener('keydown', handleKeyDown) + SegmentStore.addListener(SegmentConstants.ADD_SEGMENT_CLASS, addClass) + SegmentStore.addListener(SegmentConstants.REMOVE_SEGMENT_CLASS, removeClass) SegmentStore.addListener( SegmentConstants.SET_SEGMENT_PROPAGATION, - this.setAsAutopropagated, + setAsAutopropagated, ) SegmentStore.addListener( SegmentConstants.SET_SEGMENT_STATUS, - this.setSegmentStatus, + setSegmentStatus, ) SegmentStore.addListener( SegmentConstants.OPEN_SEGMENT, - this.openSegmentFromAction, + openSegmentFromAction, ) SegmentStore.addListener( SegmentConstants.FORCE_UPDATE_SEGMENT, - this.forceUpdateSegment, + forceUpdateSegment, ) CatToolStore.addListener( CatToolConstants.CLIENT_RECONNECTION, - this.clientReconnection, + clientReconnection, ) - - //Review SegmentStore.addListener( SegmentConstants.OPEN_ISSUES_PANEL, - this.openRevisionPanel, + openRevisionPanel, ) - if (this.props.segment.opened) { - setTimeout(() => { - this.openSegment() - }) - setTimeout(() => { - SegmentActions.setCurrentSegment(this.props.segment.sid) - }, 0) - } - } - componentWillUnmount() { - document.removeEventListener('keydown', this.handleKeyDown) - SegmentStore.removeListener( - SegmentConstants.ADD_SEGMENT_CLASS, - this.addClass, - ) - SegmentStore.removeListener( - SegmentConstants.REMOVE_SEGMENT_CLASS, - this.removeClass, - ) - SegmentStore.removeListener( - SegmentConstants.SET_SEGMENT_PROPAGATION, - this.setAsAutopropagated, - ) - SegmentStore.removeListener( - SegmentConstants.SET_SEGMENT_STATUS, - this.setSegmentStatus, - ) - SegmentStore.removeListener( - SegmentConstants.OPEN_SEGMENT, - this.openSegmentFromAction, - ) - SegmentStore.removeListener( - SegmentConstants.FORCE_UPDATE_SEGMENT, - this.forceUpdateSegment, - ) + // If segment was already open on mount + if (segmentRef.current.opened) { + setTimeout(() => openSegmentRef.current()) + setTimeout( + () => SegmentActions.setCurrentSegment(segmentRef.current.sid), + 0, + ) + } - CatToolStore.removeListener( - CatToolConstants.CLIENT_RECONNECTION, - this.clientReconnection, - ) + return () => { + document.removeEventListener('keydown', handleKeyDown) + SegmentStore.removeListener(SegmentConstants.ADD_SEGMENT_CLASS, addClass) + SegmentStore.removeListener( + SegmentConstants.REMOVE_SEGMENT_CLASS, + removeClass, + ) + SegmentStore.removeListener( + SegmentConstants.SET_SEGMENT_PROPAGATION, + setAsAutopropagated, + ) + SegmentStore.removeListener( + SegmentConstants.SET_SEGMENT_STATUS, + setSegmentStatus, + ) + SegmentStore.removeListener( + SegmentConstants.OPEN_SEGMENT, + openSegmentFromAction, + ) + SegmentStore.removeListener( + SegmentConstants.FORCE_UPDATE_SEGMENT, + forceUpdateSegment, + ) + CatToolStore.removeListener( + CatToolConstants.CLIENT_RECONNECTION, + clientReconnection, + ) + SegmentStore.removeListener( + SegmentConstants.OPEN_ISSUES_PANEL, + openRevisionPanel, + ) + } + }, []) // eslint-disable-line react-hooks/exhaustive-deps - //Review - SegmentStore.removeListener( - SegmentConstants.OPEN_ISSUES_PANEL, - this.openRevisionPanel, - ) - } + // Replaces getSnapshotBeforeUpdate — react to open/close transitions + const prevOpenedRef = useRef(segment.opened) + const prevSpeechToTextRef = useRef(speechToTextActive) - shouldComponentUpdate(nextProps, nextState) { - return ( - !nextProps.segImmutable.equals(this.props.segImmutable) || - !fromJS(nextState.segment_classes).equals( - fromJS(this.state.segment_classes), - ) || - nextState.autopropagated !== this.state.autopropagated || - nextState.readonly !== this.state.readonly || - nextState.selectedTextObj !== this.state.selectedTextObj || - nextProps.sideOpen !== this.props.sideOpen || - nextState.showActions !== this.state.showActions || - nextProps.clientConnected !== this.props.clientConnected || - nextProps.speechToTextActive !== this.props.speechToTextActive - ) - } + useEffect(() => { + const wasOpened = prevOpenedRef.current + const wasSpeechActive = prevSpeechToTextRef.current - getSnapshotBeforeUpdate(prevProps) { - if (!prevProps.segment.opened && this.props.segment.opened) { - this.timeoutScroll = setTimeout(() => { - SegmentActions.scrollToSegment(this.props.segment.sid) + if (!wasOpened && segment.opened) { + timeoutScrollRef.current = setTimeout(() => { + SegmentActions.scrollToSegment(segmentRef.current.sid) }, 200) setTimeout(() => { - SegmentActions.setCurrentSegment(this.props.segment.sid) + SegmentActions.setCurrentSegment(segmentRef.current.sid) }, 0) setTimeout(() => { + const seg = segmentRef.current if ( - this.props.segment.opened && + seg.opened && !config.isReview && - !SegmentStore.segmentHasIssues(this.props.segment) + !SegmentStore.segmentHasIssues(seg) ) { - SegmentActions.closeSegmentIssuePanel(this.props.segment.sid) + SegmentActions.closeSegmentIssuePanel(seg.sid) } - if (this.props.segment.opened && !this.props.segment.openComments) { - SegmentActions.closeSegmentComment(this.props.segment.sid) + if (seg.opened && !seg.openComments) { + SegmentActions.closeSegmentComment(seg.sid) } }) - } else if (prevProps.segment.opened && !this.props.segment.opened) { - clearTimeout(this.timeoutScroll) + } else if (wasOpened && !segment.opened) { + clearTimeout(timeoutScrollRef.current) setTimeout(() => { - SegmentActions.saveSegmentBeforeClose(this.props.segment) + SegmentActions.saveSegmentBeforeClose(segmentRef.current) }) } + if ( Speech2Text.enabled() && - ((!prevProps.speechToTextActive && this.props.speechToTextActive) || - (!prevProps.segment.opened && this.props.segment.opened)) + ((!wasSpeechActive && speechToTextActive) || + (!wasOpened && segment.opened)) + ) { + setTimeout(() => Speech2Text.enableMicrophone($sectionRef.current)) + } + + prevOpenedRef.current = segment.opened + prevSpeechToTextRef.current = speechToTextActive + }, [segment.opened, speechToTextActive]) + + // ------------------------------------------------------------------ + // Render helpers + // ------------------------------------------------------------------ + + const createSegmentClasses = () => { + let classes = [] + let splitGroup = segment.split_group || [] + + if (readonly) classes.push('readonly') + + if ((SegmentUtils.isIceSegment(segment) && !readonly) || secondPassLocked) { + if (segment.unlocked) { + classes.push('ice-unlocked') + } else { + classes.push('readonly') + classes.push('ice-locked') + } + } + + if (segment.status) { + classes.push('status-' + segment.status.toLowerCase()) + } else { + classes.push('status-new') + } + + if (segment.sid == splitGroup[0]) { + classes.push('splitStart') + } else if (segment.sid == splitGroup[splitGroup.length - 1]) { + classes.push('splitEnd') + } else if (splitGroup.length) { + classes.push('splitInner') + } + + if (tagProjectionEnabled && !segment.tagged) { + classes.push('enableTP') + } + if (segment.edit_area_locked) classes.push('editAreaLocked') + if (segment.inBulk) classes.push('segment-selected-inBulk') + if (segment.muted) classes.push('muted') + if (segment.opened && checkIfCanOpenSegment()) { + classes.push('editor') + classes.push('opened') + } + if (segment.modified || segment.autopropagated_from !== 0) { + classes.push('modified') + } + if (sideOpen) classes.push('slide-right') + if (segment.openSplit) classes.push('split-action') + if (segment.selected) classes.push('segment-selected') + + return classes + } + + const checkSegmentStatus = (classes) => { + if (classes.length === 0) return classes + let statusMatches = classes.join(' ').match(/status-/g) + if (statusMatches && statusMatches.length > 1) { + let index = classes.findIndex(function (item) { + return item.indexOf('status-new') > -1 + }) + if (index >= 0) classes.splice(index, 1) + } + return classes + } + + const checkSegmentClasses = () => { + let classes = segmentClasses.slice() + classes = union(classes, createSegmentClasses()) + classes = checkSegmentStatus(classes) + if (classes.indexOf('muted') > -1 && classes.indexOf('editor') > -1) { + let indexEditor = classes.indexOf('editor') + classes.splice(indexEditor, 1) + let indexOpened = classes.indexOf('opened') + classes.splice(indexOpened, 1) + } + return classes + } + + const isSplitted = () => !isUndefined(segment.split_group) + + const isFirstOfSplit = () => + !isUndefined(segment.split_group) && + segment.split_group.indexOf(segment.sid) === 0 + + const getTranslationIssues = () => { + if ( + ((sideOpen && (!segment.opened || !segment.openIssues)) || !sideOpen) && + !segment.readonly && + (!isSplitted() || (isSplitted() && isFirstOfSplit())) && + segment.sid ) { - setTimeout(() => Speech2Text.enableMicrophone(this.$section)) + return ( + + ) } return null } - componentDidUpdate() {} - - render() { - let job_marker = '' - - let readonly = this.state.readonly - let showLockIcon = - SegmentUtils.isIceSegment(this.props.segment) || this.secondPassLocked - let segment_classes = this.checkSegmentClasses() - - let split_group = this.props.segment.split_group || [] - let autoPropagable = this.props.segment.repetitions_in_chunk !== 1 - let originalId = this.props.segment.original_sid - - let translationIssues = this.getTranslationIssues() - let locked = - !this.props.segment.unlocked && - (SegmentUtils.isIceSegment(this.props.segment) || this.secondPassLocked) - const segmentHasIssues = SegmentStore.segmentHasIssues(this.props.segment) - - const getContextProps = () => { - const { - guessTagActive, - isReview, - segImmutable, - segment, - files, - speech2textEnabledFn, - multiMatchLangs, - } = this.props - return { - enableTagProjection: guessTagActive && !this.props.segment.tagged, - isReview, - segImmutable, - segment, - files, - speech2textEnabledFn, - readonly: this.state.readonly, - locked, - removeSelection: this.removeSelection.bind(this), - openSegment: this.openSegment, - clientConnected: this.props.clientConnected, - clientId: this.props.clientId, - multiMatchLangs, - userInfo: this.context.userInfo, + + const lockUnlockSegment = (event) => { + event.preventDefault() + event.stopPropagation() + if (!segment.unlocked && SegmentUtils.isSecondPassLockedSegment(segment)) { + const props = { + text: 'You are about to edit a segment that has been approved in the 2nd pass review. The project owner and 2nd pass reviser will be notified.', + successText: 'Ok', + successCallback: function () { + ModalsActions.onCloseModal() + }, } + ModalsActions.showModalComponent( + ConfirmMessageModal, + props, + 'Modify locked and approved segment ', + ) } + SegmentActions.setSegmentLocked(segment, fid, !segment.unlocked) + } - return ( - -
(this.section = section)} - id={'segment-' + this.props.segment.sid} - className={`${segment_classes.join(' ')} source-${config.source_code} target-${config.target_code} ${config.isSourceRTL ? 'rtl-source' : ''} ${config.isTargetRTL ? 'rtl-target' : ''}`} - data-autopropagated={this.state.autopropagated} - data-split-group={split_group} - data-split-original-id={originalId} - data-tagmode="crunched" - data-tagprojection={this.dataAttrTagged} - data-fid={this.props.segment.id_file} - data-modified={this.props.segment.modified} - > -
-
{this.props.segment.sid}
- - {showLockIcon ? ( - !readonly ? ( - this.props.segment.unlocked ? ( -
-
- ) : ( -
-
- ) - ) : null - ) : null} - -
- (this.bulk = node)} - checked={this.props.segment.inBulk} - onClick={this.handleChangeBulk} - /> -
+ const handleChangeBulk = (event) => { + event.stopPropagation() + if (event.shiftKey) { + setBulkSelection(segment.sid, fid) + } else { + SegmentActions.toggleSegmentOnBulk(segment.sid, fid) + setLastSelectedSegment(segment.sid, fid) + } + } - {!this.props.segment.ice_locked && - config.splitSegmentEnabled && - this.props.segment.opened ? ( - !this.props.segment.openSplit ? ( -
- -

CTRL + S

+ const onClickEvent = () => { + if (readonly || (!segment.unlocked && SegmentUtils.isIceSegment(segment))) { + SegmentActions.handleClickOnReadOnly(segment) + } else if (segment.muted) { + return + } else if (!segment.opened) { + openSegmentRef.current() + if (checkIfCanOpenSegment()) { + SegmentActions.setOpenSegment(segment.sid, fid) + } + } + } + + // ------------------------------------------------------------------ + // Render + // ------------------------------------------------------------------ + + const showLockIcon = SegmentUtils.isIceSegment(segment) || secondPassLocked + const segment_classes = checkSegmentClasses() + const split_group = segment.split_group || [] + const autoPropagable = segment.repetitions_in_chunk !== 1 + const originalId = segment.original_sid + const translationIssues = getTranslationIssues() + const locked = + !segment.unlocked && + (SegmentUtils.isIceSegment(segment) || secondPassLocked) + const segmentHasIssues = SegmentStore.segmentHasIssues(segment) + + const contextValue = { + enableTagProjection: guessTagActive && !segment.tagged, + isReview, + segImmutable, + segment, + files, + speech2textEnabledFn, + readonly, + locked, + removeSelection, + openSegment, + clientConnected, + clientId, + multiMatchLangs, + userInfo, + } + + return ( + +
+
+
{segment.sid}
+ + {showLockIcon ? ( + !readonly ? ( + segment.unlocked ? ( +
+
) : ( -
- - {/*

CTRL + W

*/} +
+
) - ) : null} -
- {job_marker} - -
- + - - {SegmentFilter && SegmentFilter.enabled() ? ( -
- Edit Distance: {this.props.segment.edit_distance} -
- ) : null} - - {this.props.segment.opened ? : null}
- {/*//!-- TODO: place this element here only if it's not a split --*/} -
- {config.comments_enabled && - (!this.props.segment.openComments || !this.props.segment.opened) ? ( - - ) : null} - - - {this.props.isReview && ( -
- {translationIssues} + {!segment.ice_locked && + config.splitSegmentEnabled && + segment.opened ? ( + !segment.openSplit ? ( +
+ +

CTRL + S

- )} -
-
- {config.comments_enabled && this.props.segment.openComments ? ( - - ) : null} - {config.isReview && - this.props.segment.openIssues && - this.props.segment.opened && - (config.isReview || (!config.isReview && segmentHasIssues)) ? ( -
- {!this.props.segment.versions ? null : ( - - )} + ) : ( +
+
- ) : null} -
-
-
- ) - } + ) + ) : null} +
+ +
+ + + {SegmentFilter && SegmentFilter.enabled() ? ( +
+ Edit Distance: {segment.edit_distance} +
+ ) : null} + + {segment.opened ? : null} +
+ +
+ {config.comments_enabled && + (!segment.openComments || !segment.opened) ? ( + + ) : null} + + + {isReview && ( +
+ {translationIssues} +
+ )} +
+
+ {config.comments_enabled && segment.openComments ? ( + + ) : null} + {config.isReview && + segment.openIssues && + segment.opened && + (config.isReview || (!config.isReview && segmentHasIssues)) ? ( +
+ {!segment.versions ? null : ( + + )} +
+ ) : null} +
+
+
+ ) } +const Segment = React.memo( + SegmentComponent, + (prevProps, nextProps) => + nextProps.segImmutable.equals(prevProps.segImmutable) && + nextProps.sideOpen === prevProps.sideOpen && + nextProps.clientConnected === prevProps.clientConnected && + nextProps.speechToTextActive === prevProps.speechToTextActive, +) + +Segment.displayName = 'Segment' + export default Segment diff --git a/public/js/components/segments/SegmentsContainer.js b/public/js/components/segments/SegmentsContainer.js index c3785b70b3..396770dbc2 100644 --- a/public/js/components/segments/SegmentsContainer.js +++ b/public/js/components/segments/SegmentsContainer.js @@ -27,6 +27,7 @@ import SegmentUtils from '../../utils/segmentUtils' import CommentsStore from '../../stores/CommentsStore' import DraftMatecatUtils from './utils/DraftMatecatUtils' import {ApplicationWrapperContext} from '../common/ApplicationWrapper/ApplicationWrapperContext' +import useContextReviewChannel from '../../hooks/useContextReviewChannel' const ROW_MARGIN = 3 const ROW_HEIGHT = 90 @@ -177,7 +178,7 @@ function SegmentsContainer({isReview, startSegmentId, firstJobSegment}) { const rowsRenderedHeight = useRef(new Map()) const cachedRowsHeightMap = useRef(new Map()) const cachedSegmentsToJS = useRef(new Map()) - + const {sendMessage} = useContextReviewChannel() const {guess_tags: guessTagActive, dictation: speechToTextActive} = userInfo?.metadata ?? {} @@ -429,6 +430,21 @@ function SegmentsContainer({isReview, startSegmentId, firstJobSegment}) { return () => window.removeEventListener('resize', onWindowResize) }, []) + // Send segment mappings to ContextReview when segments change + useEffect(() => { + if (!segments.size) return + const segmentsList = [] + for (let i = 0; i < segments.size; i++) { + const seg = segments.get(i) + segmentsList.push({ + sid: seg.get('sid'), + source: seg.get('segment'), + target: seg.get('translation'), + }) + } + sendMessage({type: 'segments', segments: segmentsList}) + }, [segments, sendMessage]) + // add actions listener useEffect(() => { let wasRemovedAllSegments = false @@ -447,6 +463,13 @@ function SegmentsContainer({isReview, startSegmentId, firstJobSegment}) { persistenceVariables.current.lastScrolled = sid setScrollToSid(sid) setScrollToSelected(false) + const segment = SegmentStore.getSegmentById(sid) + sendMessage({ + type: 'highlight', + sid, + source: segment.get('segment'), + target: segment.get('translation'), + }) } const scrollToSelectedSegment = (sid) => { setScrollToSid(sid) diff --git a/public/js/hooks/useContextReviewChannel.js b/public/js/hooks/useContextReviewChannel.js new file mode 100644 index 0000000000..79f3fdff4e --- /dev/null +++ b/public/js/hooks/useContextReviewChannel.js @@ -0,0 +1,44 @@ +import {useEffect, useRef, useCallback} from 'react' +import PropTypes from 'prop-types' +import ContextReviewChannel from '../utils/contextReviewChannel' + +/** + * React hook that subscribes to incoming ContextReviewChannel messages + * and provides a stable `sendMessage` function. + * + * Thin wrapper around the singleton `ContextReviewChannel` utility, + * so it can also be used from class components or plain JS via the + * singleton directly. + * + * @param {Object} [params] + * @param {Function} [params.onMessage] - Callback invoked when a message is received + * @returns {{sendMessage: Function}} + */ +function useContextReviewChannel({onMessage} = {}) { + const onMessageRef = useRef(onMessage) + + useEffect(() => { + onMessageRef.current = onMessage + }) + + useEffect(() => { + const off = ContextReviewChannel.onMessage((data) => { + if (onMessageRef.current) { + onMessageRef.current(data) + } + }) + return off + }, []) + + const sendMessage = useCallback((message) => { + ContextReviewChannel.sendMessage(message) + }, []) + + return {sendMessage} +} + +useContextReviewChannel.propTypes = { + onMessage: PropTypes.func, +} + +export default useContextReviewChannel diff --git a/public/js/pages/CatTool.js b/public/js/pages/CatTool.js index 8a46e3f63e..f0fc192dc9 100644 --- a/public/js/pages/CatTool.js +++ b/public/js/pages/CatTool.js @@ -49,6 +49,7 @@ import {CatToolInterface} from './CatToolInterface' import CommentsActions from '../actions/CommentsActions' import ModalsActions from '../actions/ModalsActions' import FatalErrorModal from '../components/modals/FatalErrorModal' +import useContextReviewChannel from '../hooks/useContextReviewChannel' const urlParams = new URLSearchParams(window.location.search) const initialStateIsOpenSettings = Boolean(urlParams.get('openTab')) @@ -79,7 +80,13 @@ function CatTool() { const startSegmentIdRef = useRef() const callbackAfterSegmentsResponseRef = useRef() - + const {sendMessage} = useContextReviewChannel({ + onMessage: (message) => { + if (message.type === 'segmentClicked' && message.sid) { + SegmentActions.openSegment(message.sid) + } + }, + }) const {isLoading: isLoadingSegments, result: segmentsResult} = useSegmentsLoader({ segmentId: options?.segmentId @@ -334,10 +341,6 @@ function CatTool() { const {segmentId, data} = segmentsResult if (where === 'center') { - // Init segments - // TODO: da verificare se serve: $(document).trigger('segments:load', data) - $(document).trigger('segments:load', data) - if ( !Object.entries(data.files) .map(([, value]) => value.segments) @@ -381,10 +384,23 @@ function CatTool() { // TODO: da verificare se serve: $(window).trigger('segmentsAdded', {resp: data.files}) CommonUtils.dispatchCustomEvent('segmentsAdded', {resp: data.files}) } + const segmentsFlat = Object.entries(data.files) + .map(([, value]) => value.segments) + .flat() + const segmentsList = [] + for (let i = 0; i < segmentsFlat.length; i++) { + const seg = segmentsFlat[i] + segmentsList.push({ + sid: seg.sid, + source: seg.segment, + target: seg.translation, + }) + } + sendMessage({type: 'segments', segments: segmentsList}) if (config.isReview) { SegmentActions.addPreloadedIssuesToSegment() } - }, [segmentsResult, options?.openCurrentSegmentAfter]) + }, [segmentsResult, options?.openCurrentSegmentAfter, sendMessage]) // execute callback option from onRender action useEffect(() => { diff --git a/public/js/pages/ContextReview.js b/public/js/pages/ContextReview.js index 47667468a0..d44021af8b 100644 --- a/public/js/pages/ContextReview.js +++ b/public/js/pages/ContextReview.js @@ -1,18 +1,207 @@ -import React, {useContext} from 'react' +import React, {useEffect, useRef, useState, useCallback} from 'react' import {mountPage} from './mountPage' -import {ApplicationWrapperContext} from '../components/common/ApplicationWrapper/ApplicationWrapperContext' -import {CookieConsent} from '../components/common/CookieConsent' +import useContextReviewChannel from '../hooks/useContextReviewChannel' +import { + clearHighlights, + highlightBySid, + findSegmentSidByClick, + tagSegments, +} from '../utils/contextReviewUtils' +import sampleHtml from '../../../sample-context-review.html' + +/** + * Parses the sample HTML string and extracts style + body content. + * + * @param {string} rawHtml + * @returns {string} Combined style tags and body innerHTML + */ +const parseHtmlContent = (rawHtml) => { + const parser = new DOMParser() + const doc = parser.parseFromString(rawHtml, 'text/html') + + const styles = doc.querySelectorAll('head style') + let styleHtml = '' + styles.forEach((style) => { + styleHtml += style.outerHTML + }) + + const bodyHtml = doc.body ? doc.body.innerHTML : rawHtml + return styleHtml + bodyHtml +} const ContextReview = () => { - return ( - <> + const [htmlContent, setHtmlContent] = useState('') + const [segments, setSegments] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const sourceRef = useRef(null) + const targetRef = useRef(null) + const segmentsRef = useRef([]) + + // Keep segmentsRef in sync so callbacks always see the latest value + useEffect(() => { + segmentsRef.current = segments + }, [segments]) + + const handleMessage = useCallback((message) => { + if (message.type === 'segments') { + const incoming = message.segments ?? [] + setSegments((prev) => { + const existingSids = new Set(prev.map((s) => s.sid)) + const newSegments = incoming.filter((s) => !existingSids.has(s.sid)) + return newSegments.length > 0 ? [...prev, ...newSegments] : prev + }) + } + + if (message.type === 'highlight') { + // Highlight on both panels using data-context-sid attribute + if (sourceRef.current) { + clearHighlights(sourceRef.current) + const firstMatch = highlightBySid(sourceRef.current, message.sid) + if (firstMatch) { + firstMatch.scrollIntoView({behavior: 'smooth', block: 'center'}) + } + } + if (targetRef.current) { + clearHighlights(targetRef.current) + const firstMatch = highlightBySid(targetRef.current, message.sid) + if (firstMatch) { + firstMatch.scrollIntoView({behavior: 'smooth', block: 'center'}) + } + } + } + + if (message.type === 'updateTranslation') { + setSegments((prev) => + prev.map((seg) => + seg.sid === message.sid ? {...seg, target: message.target} : seg, + ), + ) + } + }, []) + + const {sendMessage} = useContextReviewChannel({onMessage: handleMessage}) + + // Parse the imported HTML string + useEffect(() => { + try { + setLoading(true) + setError(null) + setHtmlContent(parseHtmlContent(sampleHtml)) + } catch (e) { + setError(e.message) + } finally { + setLoading(false) + } + }, []) + + // When segments change, apply text replacements to the target panel + // and tag source panel nodes with SID attributes + useEffect(() => { + if (!segments.length || !htmlContent) return + + // Re-render fresh HTML in target panel then apply replacements + if (targetRef.current) { + targetRef.current.innerHTML = htmlContent + tagSegments(targetRef.current, segments, {replaceWithTarget: true}) + } + + // Tag source panel nodes with SID attributes for click resolution + if (sourceRef.current) { + sourceRef.current.innerHTML = htmlContent + tagSegments(sourceRef.current, segments) + } + }, [segments, htmlContent]) + + // Attach click listeners to both panels + useEffect(() => { + const sourceContainer = sourceRef.current + const targetContainer = targetRef.current + if (!htmlContent) return + + const handleSourceClick = (event) => { + const sid = findSegmentSidByClick( + event.target, + sourceContainer, + segmentsRef.current, + 'source', + ) + if (sid != null) { + sendMessage({type: 'segmentClicked', sid}) + } + } + + const handleTargetClick = (event) => { + const sid = findSegmentSidByClick( + event.target, + targetContainer, + segmentsRef.current, + 'target', + ) + if (sid != null) { + sendMessage({type: 'segmentClicked', sid}) + } + } + + if (sourceContainer) { + sourceContainer.addEventListener('click', handleSourceClick) + } + if (targetContainer) { + targetContainer.addEventListener('click', handleTargetClick) + } + + return () => { + if (sourceContainer) { + sourceContainer.removeEventListener('click', handleSourceClick) + } + if (targetContainer) { + targetContainer.removeEventListener('click', handleTargetClick) + } + } + }, [htmlContent, sendMessage]) + + if (loading) { + return (
-

Context Review

+
Loading document...
+
+ ) + } + + if (error) { + return ( +
+
+

Error loading document

+

{error}

+
+
+ ) + } + + return ( +
+
+
+
Source
+
+
+
+
+
Translation
+
+
-
- -
- +
) } diff --git a/public/js/utils/contextReviewChannel.js b/public/js/utils/contextReviewChannel.js new file mode 100644 index 0000000000..0f47d88e58 --- /dev/null +++ b/public/js/utils/contextReviewChannel.js @@ -0,0 +1,93 @@ +const CHANNEL_NAME = 'matecat-context-review' + +/** + * Singleton service that manages a single BroadcastChannel for communication + * between the CatTool page and the ContextReview page. + * + * Can be used from any context — functional components, class components, + * Flux actions, or plain utility code. + * + * Message protocol: + * - CatTool -> ContextReview: {type: 'segments', segments: [{sid, source, target}, ...]} + * Sends the full segment mapping so ContextReview can build the target panel. + * - CatTool -> ContextReview: {type: 'highlight', sid: number, source: string, target: string} + * Highlights a single segment on both source and target panels. + * - CatTool -> ContextReview: {type: 'updateTranslation', sid: number, target: string} + * Updates the translation for a single segment in the target panel. + * - ContextReview -> CatTool: {type: 'segmentClicked', sid: number} + * Reports which segment was clicked in either panel. + * + * Usage: + * import ContextReviewChannel from '../../utils/contextReviewChannel' + * + * // Send from anywhere (class components, hooks, actions, etc.) + * ContextReviewChannel.sendMessage({type: 'highlight', sid: 123, source: '...', target: '...'}) + * + * // Listen for incoming messages (cleaned up automatically on close) + * const off = ContextReviewChannel.onMessage((msg) => { ... }) + * off() // unsubscribe + */ +const ContextReviewChannel = { + /** @type {BroadcastChannel|null} */ + _channel: null, + + /** @type {Set} */ + _listeners: new Set(), + + /** + * Opens the BroadcastChannel. Safe to call multiple times — + * subsequent calls are no-ops if already open. + */ + open() { + if (this._channel) return + + this._channel = new BroadcastChannel(CHANNEL_NAME) + + this._channel.onmessage = (event) => { + this._listeners.forEach((fn) => { + try { + fn(event.data) + } catch (e) { + console.error('[ContextReviewChannel] Listener error:', e) + } + }) + } + }, + + /** + * Closes the BroadcastChannel and removes all listeners. + */ + close() { + if (this._channel) { + this._channel.close() + this._channel = null + } + this._listeners.clear() + }, + + /** + * Sends a message to the other side of the channel. + * Opens the channel automatically if not already open. + * + * @param {Object} message + */ + sendMessage(message) { + if (!this._channel) this.open() + this._channel.postMessage(message) + }, + + /** + * Registers a listener for incoming messages. + * Opens the channel automatically if not already open. + * + * @param {Function} callback - Called with the message data + * @returns {Function} Unsubscribe function + */ + onMessage(callback) { + if (!this._channel) this.open() + this._listeners.add(callback) + return () => this._listeners.delete(callback) + }, +} + +export default ContextReviewChannel diff --git a/public/js/utils/contextReviewUtils.js b/public/js/utils/contextReviewUtils.js new file mode 100644 index 0000000000..0309247202 --- /dev/null +++ b/public/js/utils/contextReviewUtils.js @@ -0,0 +1,222 @@ +const HIGHLIGHT_CLASS = 'context-review-highlight' +const HIGHLIGHT_ACTIVE_CLASS = 'context-review-highlight--active' +const SEGMENT_SID_ATTR = 'data-context-sid' + +/** + * Clears all existing highlights from the container by unwrapping + * elements back to their original text nodes. + * + * @param {HTMLElement} container + */ +export const clearHighlights = (container) => { + const marks = container.querySelectorAll(`mark.${HIGHLIGHT_CLASS}`) + marks.forEach((mark) => { + const parent = mark.parentNode + const textNode = document.createTextNode(mark.textContent) + parent.replaceChild(textNode, mark) + parent.normalize() + }) +} + +/** + * Builds a case-insensitive regex from searchText where whitespace + * boundaries are flexible — any whitespace in either side (or none at all) + * matches any whitespace in the other. This handles mismatches like: + * search "Hello World" vs node "Hello\nWorld" + * search "HelloWorld" vs node "Hello\nWorld" + * + * @param {string} searchText + * @returns {RegExp} + */ +export const buildFlexibleRegex = (searchText) => { + const escaped = searchText.trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const tokens = escaped.split(/\s+/) + return new RegExp(tokens.join('\\s*'), 'gi') +} + +/** + * Highlights all elements in a container that have a matching + * `data-context-sid` attribute. Wraps their text content in elements + * and returns the first one for scrolling. + * + * @param {HTMLElement} container + * @param {number|string} sid - The segment ID to highlight + * @returns {HTMLElement|null} The first element, or null if no match found + */ +export const highlightBySid = (container, sid) => { + if (!container || sid == null) return null + + const elements = container.querySelectorAll(`[${SEGMENT_SID_ATTR}="${sid}"]`) + if (!elements.length) return null + + let firstMark = null + + elements.forEach((el) => { + const mark = document.createElement('mark') + mark.className = HIGHLIGHT_CLASS + mark.textContent = el.textContent + + if (!firstMark) { + firstMark = mark + mark.classList.add(HIGHLIGHT_ACTIVE_CLASS) + } + + el.textContent = '' + el.appendChild(mark) + }) + + return firstMark +} + +/** + * Tags for elements that represent meaningful content blocks in the document. + * Used to resolve clicks to segment-level granularity. + */ +const MEANINGFUL_TAGS = ['P', 'LI', 'TD', 'TH', 'H1', 'H2', 'H3', 'H4', 'DIV'] + +/** + * Finds the closest meaningful parent element for a clicked node. + * + * @param {HTMLElement} clickedElement + * @param {HTMLElement} container + * @returns {HTMLElement} The meaningful parent, or the clicked element itself + */ +const findMeaningfulParent = (clickedElement, container) => { + let target = clickedElement + + while (target && target !== container) { + if (MEANINGFUL_TAGS.includes(target.tagName)) break + target = target.parentElement + } + + if (!target || target === container) { + target = clickedElement + } + + return target +} + +/** + * Finds the segment SID that matches the text content of a clicked element. + * First checks for a `data-context-sid` attribute on the element or its ancestors, + * then falls back to fuzzy-matching the text against the segments list. + * + * @param {HTMLElement} clickedElement - The element that was clicked + * @param {HTMLElement} container - The panel container element + * @param {Array<{sid: number, source: string, target: string}>} segments - The segments mapping + * @param {'source'|'target'} field - Which field to match against + * @returns {number|null} The matching segment SID, or null if not found + */ +export const findSegmentSidByClick = ( + clickedElement, + container, + segments, + field, +) => { + if (!segments || !segments.length) return null + + // 1. Check for data-context-sid on the clicked element or any ancestor + const sidEl = clickedElement.closest(`[${SEGMENT_SID_ATTR}]`) + if (sidEl) { + return parseInt(sidEl.getAttribute(SEGMENT_SID_ATTR), 10) + } + + // 2. Fallback: fuzzy-match the clicked text against segments + const targetEl = findMeaningfulParent(clickedElement, container) + const clickedText = targetEl.textContent.replace(/\s+/g, ' ').trim() + if (!clickedText) return null + + for (const seg of segments) { + const segText = seg[field] + if (!segText) continue + const regex = buildFlexibleRegex(segText) + regex.lastIndex = 0 + if (regex.test(clickedText)) { + return seg.sid + } + } + + return null +} + +/** + * Walks all text nodes in a container, matches them against segment source texts, + * and wraps matches in `` elements. + * + * When `replaceWithTarget` is true the matched text is swapped with + * `seg.target` (falling back to the original text when target is empty). + * When false the original text is always kept. + * + * Segments are processed longest-first to avoid partial replacements. + * Already-tagged nodes are skipped to prevent double-wrapping. + * + * @param {HTMLElement} container - The DOM container to process (modified in place) + * @param {Array<{sid: number, source: string, target: string}>} segments + * @param {Object} [options] + * @param {boolean} [options.replaceWithTarget=false] - Substitute matched text with target + */ +export const tagSegments = (container, segments, {replaceWithTarget = false} = {}) => { + if (!container || !segments || !segments.length) return + + const sorted = [...segments] + .filter((s) => s.source) + .sort((a, b) => b.source.length - a.source.length) + + sorted.forEach((seg) => { + const flexRegex = buildFlexibleRegex(seg.source) + const treeWalker = document.createTreeWalker( + container, + NodeFilter.SHOW_TEXT, + null, + ) + + const matchingNodes = [] + while (treeWalker.nextNode()) { + const textNode = treeWalker.currentNode + // Skip nodes already tagged + if (textNode.parentNode.closest(`[${SEGMENT_SID_ATTR}]`)) continue + flexRegex.lastIndex = 0 + if (flexRegex.test(textNode.nodeValue)) { + matchingNodes.push(textNode) + } + } + + matchingNodes.forEach((textNode) => { + const text = textNode.nodeValue + const parent = textNode.parentNode + const fragment = document.createDocumentFragment() + + const regex = buildFlexibleRegex(seg.source) + let lastIndex = 0 + let match + + while ((match = regex.exec(text)) !== null) { + const matchStart = match.index + const matchEnd = matchStart + match[0].length + + if (matchStart > lastIndex) { + fragment.appendChild( + document.createTextNode(text.slice(lastIndex, matchStart)), + ) + } + + const originalText = text.slice(matchStart, matchEnd) + const span = document.createElement('span') + span.setAttribute(SEGMENT_SID_ATTR, seg.sid) + span.textContent = + replaceWithTarget && seg.target ? seg.target : originalText + fragment.appendChild(span) + + lastIndex = matchEnd + } + + if (lastIndex < text.length) { + fragment.appendChild(document.createTextNode(text.slice(lastIndex))) + } + + if (lastIndex > 0) { + parent.replaceChild(fragment, textNode) + } + }) + }) +} diff --git a/webpack.config.js b/webpack.config.js index 345bdd1db5..11e901a3ab 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -213,6 +213,15 @@ const matecatConfig = async ({env}, {mode}) => { filename: 'fonts/[name][ext]', }, }, + { + test: /\.html$/i, + include: [path.resolve(__dirname)], + exclude: [ + path.resolve(__dirname, 'lib/View'), + path.resolve(__dirname, 'node_modules'), + ], + type: 'asset/source', + }, ], }, entry: { From bc2728d0885426fc97b90885a55d0fbb1180c950 Mon Sep 17 00:00:00 2001 From: riccio82 Date: Mon, 16 Mar 2026 17:38:50 +0100 Subject: [PATCH 003/116] Context review page --- public/sample-context-review.xliff | 444 +++++++++++++++++++++++++++++ sample-context-review.html | 335 ++++++++++++++++++++++ 2 files changed, 779 insertions(+) create mode 100644 public/sample-context-review.xliff create mode 100644 sample-context-review.html diff --git a/public/sample-context-review.xliff b/public/sample-context-review.xliff new file mode 100644 index 0000000000..2b5041ea17 --- /dev/null +++ b/public/sample-context-review.xliff @@ -0,0 +1,444 @@ + + + + + + + CloudSync Pro — User Guide + + + + + + Version 3.2 — Last updated: January 2026 + + + + + + 1. Introduction + + + + + + CloudSync Pro is a file synchronization and backup service designed for teams and individual professionals. It provides real-time synchronization across all your devices, ensuring that your files are always up to date and securely stored in the cloud. + + + + + + This guide covers the essential features of CloudSync Pro, including installation, configuration, file management, and troubleshooting. For advanced topics such as API integration and enterprise deployment, please refer to the Developer Reference Manual. + + + + + + 2. System Requirements + + + + + + Before installing CloudSync Pro, please ensure that your system meets the following minimum requirements: + + + + + + Operating System: Windows 10 or later, macOS 12 or later, or Ubuntu 20.04 or later + + + + + + Processor: 64-bit processor with at least 2 cores + + + + + + Memory: 4 GB RAM minimum (8 GB recommended for large file sets) + + + + + + Storage: 500 MB of available disk space for the application, plus sufficient space for synchronized files + + + + + + Network: Broadband internet connection with a minimum upload speed of 5 Mbps + + + + + + Note: CloudSync Pro requires an active internet connection for initial setup and synchronization. Offline editing is supported after the first sync is complete. + + + + + + 3. Installation + + + + + + 3.1 Downloading the Installer + + + + + + Visit the official CloudSync Pro website and navigate to the Downloads section. Select the installer that matches your operating system. The download should begin automatically within a few seconds. + + + + + + 3.2 Running the Installation Wizard + + + + + + Locate the downloaded installer file in your Downloads folder. + + + + + + Double-click the installer to launch the setup wizard. + + + + + + Accept the End User License Agreement to proceed. + + + + + + Choose the installation directory. The default location is recommended for most users. + + + + + + Select whether to create a desktop shortcut and enable automatic startup on login. + + + + + + Click Install and wait for the process to complete. + + + + + + Click Finish to close the wizard and launch CloudSync Pro. + + + + + + 3.3 Initial Configuration + + + + + + When you launch CloudSync Pro for the first time, you will be prompted to sign in with your account credentials. If you do not have an account, you can create one directly from the login screen. + + + + + + After signing in, you will be asked to select a local folder to use as your synchronization root. All files placed in this folder will be automatically uploaded to the cloud and synchronized across your connected devices. + + + + + + 4. File Management + + + + + + 4.1 Synchronizing Files + + + + + + CloudSync Pro monitors your synchronization folder in real time. Any file added, modified, or deleted within this folder will be automatically reflected in the cloud and on all other connected devices. The synchronization process runs in the background and requires no manual intervention. + + + + + + Warning: Renaming or moving files outside the synchronization folder will remove them from the cloud. Always manage your files within the designated sync folder to avoid accidental data loss. + + + + + + 4.2 Sharing Files and Folders + + + + + + You can share files and folders with other CloudSync Pro users by right-clicking the item and selecting Share. You may grant read-only or read-write access to specific users or generate a public link that can be shared with anyone. + + + + + + Shared folders appear in the recipient's synchronization root under a Shared with Me section. Changes made by any collaborator are synchronized in real time. + + + + + + 4.3 Version History + + + + + + CloudSync Pro automatically retains previous versions of your files for up to 90 days. To restore an earlier version, right-click the file, select Version History, and choose the version you wish to recover. The restored version will replace the current file and trigger a new synchronization event. + + + + + + 5. Security and Privacy + + + + + + All files stored in CloudSync Pro are encrypted using AES-256 encryption both at rest and in transit. Your encryption keys are managed by our secure key management infrastructure, and no CloudSync Pro employee has access to your unencrypted data. + + + + + + Two-factor authentication is available and strongly recommended for all accounts. You can enable it from the Security section of your account settings. Supported methods include authenticator applications and hardware security keys. + + + + + + 6. Subscription Plans + + + + + + CloudSync Pro offers several subscription plans to meet different needs. The following table summarizes the available options: + + + + + + Plan + + + + + + Storage + + + + + + Users + + + + + + Price (Monthly) + + + + + + Key Features + + + + + + Personal + + + + + + 100 GB + + + + + + $4.99 + + + + + + Basic sync, version history, mobile access + + + + + + Professional + + + + + + 1 TB + + + + + + $12.99 + + + + + + Priority support, advanced sharing, API access + + + + + + Team + + + + + + 5 TB + + + + + + Up to 10 + + + + + + $29.99 + + + + + + Admin console, team folders, audit logs + + + + + + Enterprise + + + + + + Unlimited + + + + + + Contact Sales + + + + + + SSO integration, dedicated support, custom SLA + + + + + + 7. Troubleshooting + + + + + + 7.1 Sync Not Starting + + + + + + If synchronization does not begin after placing files in the sync folder, verify that the CloudSync Pro application is running in the system tray. Check your internet connection and ensure that your firewall is not blocking the application. Restarting the application often resolves temporary synchronization issues. + + + + + + 7.2 Conflicting File Versions + + + + + + When two users edit the same file simultaneously, CloudSync Pro may detect a conflict. In this case, the application saves both versions and appends a conflict marker to the filename of the secondary copy. You can review both files and merge the changes manually. + + + + + + 7.3 Insufficient Storage + + + + + + If you receive a notification that your cloud storage is full, consider upgrading your subscription plan or removing unnecessary files. You can review your storage usage from the Settings panel under the Storage tab. + + + + + + 8. Contact and Support + + + + + + For technical support, please visit our Help Center or contact our support team by email. Our standard response time is within 24 hours for all paid plans. Enterprise customers have access to a dedicated support line with guaranteed response within 4 hours. + + + + + + We value your feedback and continuously work to improve CloudSync Pro. If you have suggestions or feature requests, please submit them through the Feedback section of the application. + + + + + diff --git a/sample-context-review.html b/sample-context-review.html new file mode 100644 index 0000000000..eb956ed649 --- /dev/null +++ b/sample-context-review.html @@ -0,0 +1,335 @@ + + + + + + CloudSync Pro — User Guide + + + +

CloudSync Pro — User Guide

+

Version 3.2 — Last updated: January 2026

+ +

1. Introduction

+

+ CloudSync Pro is a file synchronization and backup service designed for + teams and individual professionals. It provides real-time synchronization + across all your devices, ensuring that your files are always up to date + and securely stored in the cloud. +

+

+ This guide covers the essential features of CloudSync Pro, including + installation, configuration, file management, and troubleshooting. For + advanced topics such as API integration and enterprise deployment, please + refer to the Developer Reference Manual. +

+ +

2. System Requirements

+

+ Before installing CloudSync Pro, please ensure that your system meets the + following minimum requirements: +

+
    +
  • + Operating System: Windows 10 or later, macOS 12 or later, or Ubuntu + 20.04 or later +
  • +
  • Processor: 64-bit processor with at least 2 cores
  • +
  • Memory: 4 GB RAM minimum (8 GB recommended for large file sets)
  • +
  • + Storage: 500 MB of available disk space for the application, plus + sufficient space for synchronized files +
  • +
  • + Network: Broadband internet connection with a minimum upload speed of 5 + Mbps +
  • +
+ +
+ Note: CloudSync Pro requires an active internet connection for initial + setup and synchronization. Offline editing is supported after the first + sync is complete. +
+ +

3. Installation

+ +

3.1 Downloading the Installer

+

+ Visit the official CloudSync Pro website and navigate to the Downloads + section. Select the installer that matches your operating system. The + download should begin automatically within a few seconds. +

+ +

3.2 Running the Installation Wizard

+
    +
  1. Locate the downloaded installer file in your Downloads folder.
  2. +
  3. Double-click the installer to launch the setup wizard.
  4. +
  5. Accept the End User License Agreement to proceed.
  6. +
  7. + Choose the installation directory. The default location is recommended + for most users. +
  8. +
  9. + Select whether to create a desktop shortcut and enable automatic startup + on login. +
  10. +
  11. Click Install and wait for the process to complete.
  12. +
  13. Click Finish to close the wizard and launch CloudSync Pro.
  14. +
+ +

3.3 Initial Configuration

+

+ When you launch CloudSync Pro for the first time, you will be prompted to + sign in with your account credentials. If you do not have an account, you + can create one directly from the login screen. +

+

+ After signing in, you will be asked to select a local folder to use as + your synchronization root. All files placed in this folder will be + automatically uploaded to the cloud and synchronized across your connected + devices. +

+ +

4. File Management

+ +

4.1 Synchronizing Files

+

+ CloudSync Pro monitors your synchronization folder in real time. Any file + added, modified, or deleted within this folder will be automatically + reflected in the cloud and on all other connected devices. The + synchronization process runs in the background and requires no manual + intervention. +

+ +
+ Warning: Renaming or moving files outside the synchronization folder will + remove them from the cloud. Always manage your files within the designated + sync folder to avoid accidental data loss. +
+ +

4.2 Sharing Files and Folders

+

+ You can share files and folders with other CloudSync Pro users by + right-clicking the item and selecting Share. You may grant read-only or + read-write access to specific users or generate a public link that can be + shared with anyone. +

+

+ Shared folders appear in the recipient's synchronization root under a + Shared with Me section. Changes made by any collaborator are synchronized + in real time. +

+ +

4.3 Version History

+

+ CloudSync Pro automatically retains previous versions of your files for up + to 90 days. To restore an earlier version, right-click the file, select + Version History, and choose the version you wish to recover. The restored + version will replace the current file and trigger a new synchronization + event. +

+ +

5. Security and Privacy

+

+ All files stored in CloudSync Pro are encrypted using AES-256 encryption + both at rest and in transit. Your encryption keys are managed by our + secure key management infrastructure, and no CloudSync Pro employee has + access to your unencrypted data. +

+

+ Two-factor authentication is available and strongly recommended for all + accounts. You can enable it from the Security section of your account + settings. Supported methods include authenticator applications and + hardware security keys. +

+ +

6. Subscription Plans

+

+ CloudSync Pro offers several subscription plans to meet different needs. + The following table summarizes the available options: +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PlanStorageUsersPrice (Monthly)Key Features
Personal100 GB1$4.99Basic sync, version history, mobile access
Professional1 TB1$12.99Priority support, advanced sharing, API access
Team5 TBUp to 10$29.99Admin console, team folders, audit logs
EnterpriseUnlimitedUnlimitedContact SalesSSO integration, dedicated support, custom SLA
+ +

7. Troubleshooting

+ +

7.1 Sync Not Starting

+

+ If synchronization does not begin after placing files in the sync folder, + verify that the CloudSync Pro application is running in the system tray. + Check your internet connection and ensure that your firewall is not + blocking the application. Restarting the application often resolves + temporary synchronization issues. +

+ +

7.2 Conflicting File Versions

+

+ When two users edit the same file simultaneously, CloudSync Pro may detect + a conflict. In this case, the application saves both versions and appends + a conflict marker to the filename of the secondary copy. You can review + both files and merge the changes manually. +

+ +

7.3 Insufficient Storage

+

+ If you receive a notification that your cloud storage is full, consider + upgrading your subscription plan or removing unnecessary files. You can + review your storage usage from the Settings panel under the Storage tab. +

+ +

8. Contact and Support

+

+ For technical support, please visit our Help Center or contact our support + team by email. Our standard response time is within 24 hours for all paid + plans. Enterprise customers have access to a dedicated support line with + guaranteed response within 4 hours. +

+

+ We value your feedback and continuously work to improve CloudSync Pro. If + you have suggestions or feature requests, please submit them through the + Feedback section of the application. +

+ + From c47ad51dd143f271eb3ca70d657af34c301be915 Mon Sep 17 00:00:00 2001 From: riccio82 Date: Mon, 16 Mar 2026 17:54:32 +0100 Subject: [PATCH 004/116] Context review page --- public/js/components/segments/Segment.js | 1 - .../components/segments/SegmentsContainer.js | 9 ++-- public/js/hooks/useContextReviewChannel.js | 44 ------------------- public/js/pages/CatTool.js | 14 +++--- public/js/pages/ContextReview.js | 13 +++--- 5 files changed, 19 insertions(+), 62 deletions(-) delete mode 100644 public/js/hooks/useContextReviewChannel.js diff --git a/public/js/components/segments/Segment.js b/public/js/components/segments/Segment.js index 0b3113cd5f..1fe8aa07ae 100644 --- a/public/js/components/segments/Segment.js +++ b/public/js/components/segments/Segment.js @@ -34,7 +34,6 @@ import {ApplicationWrapperContext} from '../common/ApplicationWrapper/Applicatio import {Shortcuts} from '../../utils/shortcuts' import SearchUtils from '../header/cattol/search/searchUtils' import {SegmentQAIcon} from './SegmentQAIcon' -import useContextReviewChannel from '../../hooks/useContextReviewChannel' const SegmentComponent = ({ segment, diff --git a/public/js/components/segments/SegmentsContainer.js b/public/js/components/segments/SegmentsContainer.js index 396770dbc2..5d66396e9f 100644 --- a/public/js/components/segments/SegmentsContainer.js +++ b/public/js/components/segments/SegmentsContainer.js @@ -27,7 +27,7 @@ import SegmentUtils from '../../utils/segmentUtils' import CommentsStore from '../../stores/CommentsStore' import DraftMatecatUtils from './utils/DraftMatecatUtils' import {ApplicationWrapperContext} from '../common/ApplicationWrapper/ApplicationWrapperContext' -import useContextReviewChannel from '../../hooks/useContextReviewChannel' +import ContextReviewChannel from '../../utils/contextReviewChannel' const ROW_MARGIN = 3 const ROW_HEIGHT = 90 @@ -178,7 +178,6 @@ function SegmentsContainer({isReview, startSegmentId, firstJobSegment}) { const rowsRenderedHeight = useRef(new Map()) const cachedRowsHeightMap = useRef(new Map()) const cachedSegmentsToJS = useRef(new Map()) - const {sendMessage} = useContextReviewChannel() const {guess_tags: guessTagActive, dictation: speechToTextActive} = userInfo?.metadata ?? {} @@ -442,8 +441,8 @@ function SegmentsContainer({isReview, startSegmentId, firstJobSegment}) { target: seg.get('translation'), }) } - sendMessage({type: 'segments', segments: segmentsList}) - }, [segments, sendMessage]) + ContextReviewChannel.sendMessage({type: 'segments', segments: segmentsList}) + }, [segments]) // add actions listener useEffect(() => { @@ -464,7 +463,7 @@ function SegmentsContainer({isReview, startSegmentId, firstJobSegment}) { setScrollToSid(sid) setScrollToSelected(false) const segment = SegmentStore.getSegmentById(sid) - sendMessage({ + ContextReviewChannel.sendMessage({ type: 'highlight', sid, source: segment.get('segment'), diff --git a/public/js/hooks/useContextReviewChannel.js b/public/js/hooks/useContextReviewChannel.js deleted file mode 100644 index 79f3fdff4e..0000000000 --- a/public/js/hooks/useContextReviewChannel.js +++ /dev/null @@ -1,44 +0,0 @@ -import {useEffect, useRef, useCallback} from 'react' -import PropTypes from 'prop-types' -import ContextReviewChannel from '../utils/contextReviewChannel' - -/** - * React hook that subscribes to incoming ContextReviewChannel messages - * and provides a stable `sendMessage` function. - * - * Thin wrapper around the singleton `ContextReviewChannel` utility, - * so it can also be used from class components or plain JS via the - * singleton directly. - * - * @param {Object} [params] - * @param {Function} [params.onMessage] - Callback invoked when a message is received - * @returns {{sendMessage: Function}} - */ -function useContextReviewChannel({onMessage} = {}) { - const onMessageRef = useRef(onMessage) - - useEffect(() => { - onMessageRef.current = onMessage - }) - - useEffect(() => { - const off = ContextReviewChannel.onMessage((data) => { - if (onMessageRef.current) { - onMessageRef.current(data) - } - }) - return off - }, []) - - const sendMessage = useCallback((message) => { - ContextReviewChannel.sendMessage(message) - }, []) - - return {sendMessage} -} - -useContextReviewChannel.propTypes = { - onMessage: PropTypes.func, -} - -export default useContextReviewChannel diff --git a/public/js/pages/CatTool.js b/public/js/pages/CatTool.js index f0fc192dc9..bfc8285872 100644 --- a/public/js/pages/CatTool.js +++ b/public/js/pages/CatTool.js @@ -49,7 +49,7 @@ import {CatToolInterface} from './CatToolInterface' import CommentsActions from '../actions/CommentsActions' import ModalsActions from '../actions/ModalsActions' import FatalErrorModal from '../components/modals/FatalErrorModal' -import useContextReviewChannel from '../hooks/useContextReviewChannel' +import ContextReviewChannel from '../utils/contextReviewChannel' const urlParams = new URLSearchParams(window.location.search) const initialStateIsOpenSettings = Boolean(urlParams.get('openTab')) @@ -80,13 +80,13 @@ function CatTool() { const startSegmentIdRef = useRef() const callbackAfterSegmentsResponseRef = useRef() - const {sendMessage} = useContextReviewChannel({ - onMessage: (message) => { + useEffect(() => { + return ContextReviewChannel.onMessage((message) => { if (message.type === 'segmentClicked' && message.sid) { SegmentActions.openSegment(message.sid) } - }, - }) + }) + }, []) const {isLoading: isLoadingSegments, result: segmentsResult} = useSegmentsLoader({ segmentId: options?.segmentId @@ -396,11 +396,11 @@ function CatTool() { target: seg.translation, }) } - sendMessage({type: 'segments', segments: segmentsList}) + ContextReviewChannel.sendMessage({type: 'segments', segments: segmentsList}) if (config.isReview) { SegmentActions.addPreloadedIssuesToSegment() } - }, [segmentsResult, options?.openCurrentSegmentAfter, sendMessage]) + }, [segmentsResult, options?.openCurrentSegmentAfter]) // execute callback option from onRender action useEffect(() => { diff --git a/public/js/pages/ContextReview.js b/public/js/pages/ContextReview.js index d44021af8b..d8bd2844e1 100644 --- a/public/js/pages/ContextReview.js +++ b/public/js/pages/ContextReview.js @@ -1,6 +1,6 @@ import React, {useEffect, useRef, useState, useCallback} from 'react' import {mountPage} from './mountPage' -import useContextReviewChannel from '../hooks/useContextReviewChannel' +import ContextReviewChannel from '../utils/contextReviewChannel' import { clearHighlights, highlightBySid, @@ -81,7 +81,10 @@ const ContextReview = () => { } }, []) - const {sendMessage} = useContextReviewChannel({onMessage: handleMessage}) + // Subscribe to ContextReviewChannel messages + useEffect(() => { + return ContextReviewChannel.onMessage(handleMessage) + }, [handleMessage]) // Parse the imported HTML string useEffect(() => { @@ -128,7 +131,7 @@ const ContextReview = () => { 'source', ) if (sid != null) { - sendMessage({type: 'segmentClicked', sid}) + ContextReviewChannel.sendMessage({type: 'segmentClicked', sid}) } } @@ -140,7 +143,7 @@ const ContextReview = () => { 'target', ) if (sid != null) { - sendMessage({type: 'segmentClicked', sid}) + ContextReviewChannel.sendMessage({type: 'segmentClicked', sid}) } } @@ -159,7 +162,7 @@ const ContextReview = () => { targetContainer.removeEventListener('click', handleTargetClick) } } - }, [htmlContent, sendMessage]) + }, [htmlContent]) if (loading) { return ( From c2f0baa9b6aebc99cbf59010584404f731b203a2 Mon Sep 17 00:00:00 2001 From: riccio82 Date: Mon, 16 Mar 2026 17:58:52 +0100 Subject: [PATCH 005/116] Context review page --- public/js/hooks/useSegmentsLoader.js | 1 - public/js/utils/contextReviewChannel.js | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/public/js/hooks/useSegmentsLoader.js b/public/js/hooks/useSegmentsLoader.js index 1fb985bd64..0aa36ddf60 100644 --- a/public/js/hooks/useSegmentsLoader.js +++ b/public/js/hooks/useSegmentsLoader.js @@ -3,7 +3,6 @@ import PropTypes from 'prop-types' import {getSegments} from '../api/getSegments' import SegmentActions from '../actions/SegmentActions' import SegmentStore from '../stores/SegmentStore' -import CommonUtils from '../utils/commonUtils' import {ApplicationWrapperContext} from '../components/common/ApplicationWrapper/ApplicationWrapperContext' const INIT_NUM_SEGMENTS = 40 diff --git a/public/js/utils/contextReviewChannel.js b/public/js/utils/contextReviewChannel.js index 0f47d88e58..8e7a8c4578 100644 --- a/public/js/utils/contextReviewChannel.js +++ b/public/js/utils/contextReviewChannel.js @@ -41,7 +41,7 @@ const ContextReviewChannel = { open() { if (this._channel) return - this._channel = new BroadcastChannel(CHANNEL_NAME) + this._channel = new BroadcastChannel(`${CHANNEL_NAME}-${config.password}`) this._channel.onmessage = (event) => { this._listeners.forEach((fn) => { From ed6cb87f9ef0d8f90de2f2b6f4a45468263def0f Mon Sep 17 00:00:00 2001 From: riccio82 Date: Tue, 17 Mar 2026 14:24:47 +0100 Subject: [PATCH 006/116] Context review page --- public/css/sass/cattool.scss | 61 +++++++++++++- .../components/pages/ContextReviewPage.scss | 11 +++ .../components/segments/SegmentsContainer.js | 24 ++++-- public/js/hooks/useResizable.js | 53 ++++++++++++ public/js/pages/CatTool.js | 80 +++++++++++++++++++ public/js/pages/ContextReview.js | 67 +++++++++++----- 6 files changed, 271 insertions(+), 25 deletions(-) create mode 100644 public/js/hooks/useResizable.js diff --git a/public/css/sass/cattool.scss b/public/css/sass/cattool.scss index c319c43a74..c187c6bcd6 100644 --- a/public/css/sass/cattool.scss +++ b/public/css/sass/cattool.scss @@ -31,7 +31,7 @@ body.cattool { .main-container { position: relative; width: 100%; - height: 100%; + //height: 100%; flex: 1; //overflow: hidden; @@ -45,6 +45,65 @@ body.cattool { background-color: rgba(255, 255, 255, 0.4); } } + .context-review__resize-handle { + width: 100%; + height: 6px; + cursor: row-resize; + background-color: colors.$grey5; + border-top: 1px solid #ccc; + border-bottom: 1px solid #ccc; + flex-shrink: 0; + transition: background-color 0.15s; + &:hover { + background-color: #bbb; + } + } + .context-review__header { + display: flex; + align-items: center; + justify-content: space-between; + height: 32px; + padding: 0 100px; + background-color: #e8e8e8; + border-top: 1px solid #ccc; + flex-shrink: 0; + user-select: none; + } + .context-review__header-title { + font-size: 12px; + font-weight: 600; + color: #333; + } + .context-review__header-actions { + display: flex; + align-items: center; + gap: 4px; + } + .context-review__header-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: none; + border-radius: 4px; + background: transparent; + color: #555; + cursor: pointer; + &:hover { + background-color: #d0d0d0; + color: #222; + } + } + .context-review__container { + width: 100%; + flex-shrink: 0; + iframe { + width: 100%; + height: 100%; + } + } .pointer-first-segment { height: 36px; diff --git a/public/css/sass/components/pages/ContextReviewPage.scss b/public/css/sass/components/pages/ContextReviewPage.scss index 9846512773..45de469bb6 100644 --- a/public/css/sass/components/pages/ContextReviewPage.scss +++ b/public/css/sass/components/pages/ContextReviewPage.scss @@ -1,3 +1,4 @@ +@use '../common/SegmentedControl'; .context-review-page { width: 100%; max-width: unset; @@ -10,6 +11,16 @@ flex-direction: column; } +.context-review-toolbar { + display: flex; + align-items: center; + justify-content: center; + padding: 8px 16px; + background-color: #f8f9fa; + border-bottom: 1px solid #e0e0e0; + flex-shrink: 0; +} + .context-review-panels { display: flex; flex: 1; diff --git a/public/js/components/segments/SegmentsContainer.js b/public/js/components/segments/SegmentsContainer.js index 5d66396e9f..5a8ea567ec 100644 --- a/public/js/components/segments/SegmentsContainer.js +++ b/public/js/components/segments/SegmentsContainer.js @@ -414,19 +414,33 @@ function SegmentsContainer({isReview, startSegmentId, firstJobSegment}) { // set width and height of area useEffect(() => { - const onWindowResize = () => { + const recalcHeight = () => { const headerHeight = document.getElementsByTagName('header')[0].offsetHeight const footerHeight = document.getElementsByTagName('footer')[0].offsetHeight + const wrapperEl = document.getElementById('context-review-wrapper') + const wrapperHeight = wrapperEl ? wrapperEl.offsetHeight : 0 - setHeightArea(window.innerHeight - (headerHeight + footerHeight)) + setHeightArea( + window.innerHeight - (headerHeight + footerHeight + wrapperHeight), + ) } - onWindowResize() - window.addEventListener('resize', onWindowResize) + recalcHeight() + window.addEventListener('resize', recalcHeight) + + const wrapperEl = document.getElementById('context-review-wrapper') + let observer + if (wrapperEl) { + observer = new ResizeObserver(recalcHeight) + observer.observe(wrapperEl) + } - return () => window.removeEventListener('resize', onWindowResize) + return () => { + window.removeEventListener('resize', recalcHeight) + if (observer) observer.disconnect() + } }, []) // Send segment mappings to ContextReview when segments change diff --git a/public/js/hooks/useResizable.js b/public/js/hooks/useResizable.js new file mode 100644 index 0000000000..9c32c39b4e --- /dev/null +++ b/public/js/hooks/useResizable.js @@ -0,0 +1,53 @@ +import {useState, useRef, useCallback, useEffect} from 'react' + +const useResizable = ({initialHeight = 500, minHeight = 100, maxHeight}) => { + const [height, setHeight] = useState(initialHeight) + const [isDragging, setIsDragging] = useState(false) + const isDraggingRef = useRef(false) + const startYRef = useRef(0) + const startHeightRef = useRef(0) + + const handleMouseDown = useCallback((e) => { + e.preventDefault() + isDraggingRef.current = true + setIsDragging(true) + startYRef.current = e.clientY + startHeightRef.current = height + document.body.style.cursor = 'row-resize' + document.body.style.userSelect = 'none' + }, [height]) + + useEffect(() => { + const handleMouseMove = (e) => { + if (!isDraggingRef.current) return + const delta = startYRef.current - e.clientY + const computedMax = maxHeight || window.innerHeight - 200 + const newHeight = Math.min( + Math.max(startHeightRef.current + delta, minHeight), + computedMax, + ) + setHeight(newHeight) + } + + const handleMouseUp = () => { + if (!isDraggingRef.current) return + isDraggingRef.current = false + setIsDragging(false) + document.body.style.cursor = '' + document.body.style.userSelect = '' + } + + window.addEventListener('mousemove', handleMouseMove) + window.addEventListener('mouseup', handleMouseUp) + + return () => { + window.removeEventListener('mousemove', handleMouseMove) + window.removeEventListener('mouseup', handleMouseUp) + } + }, [minHeight, maxHeight]) + + return {height, isDragging, handleMouseDown} +} + +export default useResizable + diff --git a/public/js/pages/CatTool.js b/public/js/pages/CatTool.js index bfc8285872..48f8f20846 100644 --- a/public/js/pages/CatTool.js +++ b/public/js/pages/CatTool.js @@ -50,6 +50,9 @@ import CommentsActions from '../actions/CommentsActions' import ModalsActions from '../actions/ModalsActions' import FatalErrorModal from '../components/modals/FatalErrorModal' import ContextReviewChannel from '../utils/contextReviewChannel' +import useResizable from '../hooks/useResizable' +import IconRedirect from '../components/icons/IconRedirect' +import IconDown from '../components/icons/IconDown' const urlParams = new URLSearchParams(window.location.search) const initialStateIsOpenSettings = Boolean(urlParams.get('openTab')) @@ -80,6 +83,44 @@ function CatTool() { const startSegmentIdRef = useRef() const callbackAfterSegmentsResponseRef = useRef() + const { + height: contextReviewHeight, + isDragging: isResizing, + handleMouseDown: onResizeMouseDown, + } = useResizable({initialHeight: 500, minHeight: 100}) + + const [isPreviewOpen, setIsPreviewOpen] = useState(true) + const contextReviewUrl = + 'https://dev.matecat.com/context-review/300/cb41cb913ea7#103686' + const popupWindowRef = useRef(null) + + const togglePreview = useCallback(() => { + setIsPreviewOpen((prev) => { + if (!prev && popupWindowRef.current && !popupWindowRef.current.closed) { + popupWindowRef.current.close() + popupWindowRef.current = null + } + return !prev + }) + }, []) + + const openPreviewInNewWindow = useCallback(() => { + if (popupWindowRef.current && !popupWindowRef.current.closed) { + popupWindowRef.current.focus() + } else { + const width = Math.round(window.screen.width * 0.8) + const height = Math.round(window.screen.height * 0.8) + const left = Math.round((window.screen.width - width) / 2) + const top = Math.round((window.screen.height - height) / 2) + popupWindowRef.current = window.open( + contextReviewUrl, + 'contextReviewWindow', + `width=${width},height=${height},left=${left},top=${top}`, + ) + } + setIsPreviewOpen(false) + }, [contextReviewUrl]) + useEffect(() => { return ContextReviewChannel.onMessage((message) => { if (message.type === 'segmentClicked' && message.sid) { @@ -553,6 +594,45 @@ function CatTool() {
{isFreezingSegments &&
}
+
+ {isPreviewOpen && ( +
+ )} +
+ Context Review +
+ + +
+
+ {isPreviewOpen && ( +
+