Highlight spots#105
Conversation
There was a problem hiding this comment.
Pull request overview
This PR updates the SpotFix doBoard widget to improve “spot” highlighting behavior (especially around finished spots), adjusts selection/highlight internals, and tweaks the widget layout/badge count behavior.
Changes:
- Add a minimum height to the widget container to stabilize layout.
- Update highlighting logic to (de)highlight fixed spots depending on whether the finished section is expanded.
- Rework text highlighting to use DOM Ranges/TreeWalker instead of string-based HTML insertion; adjust selection prechecks; update the wrap badge to show notification count.
Reviewed changes
Copilot reviewed 3 out of 7 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| styles/doboard-widget.css | Adds min-height to the widget container. |
| js/src/widget.js | Changes task loading, finished-section highlighting toggles, and badge count logic (notifications). |
| js/src/selections.js | Relaxes selection constraints and rewrites text highlighting to use DOM operations. |
| js/src/api.js | Minor whitespace cleanup. |
| dist/doboard-widget-bundle.js | Rebuilt bundle reflecting the source changes. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const range = selection.getRangeAt(0); | ||
| // Selection must be within a single DOM element. | ||
| if (range.startContainer !== range.endContainer) { spotFixDebugLog('`spotFixGetSelectedData` skip by `Selection within several tags nodes`'); return null; } | ||
| const commonNode = range.commonAncestorContainer; | ||
| const commonElement = commonNode.nodeType === Node.ELEMENT_NODE ? commonNode : commonNode.parentElement; | ||
| if (commonElement === document.body && range.startContainer !== range.endContainer) { | ||
| spotFixDebugLog('`spotFixGetSelectedData` skip: Selection is too broad'); | ||
| return null; | ||
| } | ||
|
|
||
| // if (range.startContainer !== range.endContainer) { spotFixDebugLog('`spotFixGetSelectedData` skip by `Selection within several tags nodes`'); return null; } |
| const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null, false); | ||
|
|
| } else { | ||
| notificationsCount = localStorage.getItem('spotfix-tasks-notifications-count'); | ||
| } | ||
|
|
||
| const taskCountElement = document.getElementById('doboard_task_widget-task_count'); | ||
| if ( taskCountElement && +tasksCount ) { | ||
| taskCountElement.innerText = ksesFilter(tasksCount); | ||
| if ( taskCountElement && +notificationsCount ) { | ||
| taskCountElement.innerText = ksesFilter(notificationsCount); |
| validRanges.forEach(rangeData => { | ||
| const { startPos, endPos } = rangeData; | ||
|
|
||
| const highlightWrapper = document.createElement('span'); | ||
| highlightWrapper.className = 'doboard_task_widget-text_selection'; | ||
|
|
||
| const tooltipSpan = document.createElement('span'); | ||
| tooltipSpan.className = 'doboard_task_widget-text_selection_tooltip'; | ||
| tooltipSpan.innerHTML = tooltipHtml; | ||
|
|
||
| highlightWrapper.appendChild(tooltipSpan); | ||
|
|
||
| const range = document.createRange(); | ||
| const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null, false); | ||
|
|
||
| let node; | ||
| let currentPos = 0; | ||
| let startNode = null, startOffset = 0; | ||
| let endNode = null, endOffset = 0; | ||
|
|
||
| while ((node = walker.nextNode())) { | ||
| let nodeLength = node.nodeValue.length; | ||
|
|
||
| if (!startNode && currentPos + nodeLength >= startPos) { | ||
| startNode = node; | ||
| startOffset = startPos - currentPos; | ||
| } | ||
| if (!endNode && currentPos + nodeLength >= endPos) { | ||
| endNode = node; | ||
| endOffset = endPos - currentPos; | ||
| break; | ||
| } | ||
| currentPos += nodeLength; | ||
| } | ||
|
|
||
| if (startNode && endNode) { | ||
| try { | ||
| range.setStart(startNode, startOffset); | ||
| range.setEnd(endNode, endOffset); | ||
|
|
||
| const contents = range.extractContents(); | ||
| highlightWrapper.appendChild(contents); | ||
|
|
||
| range.insertNode(highlightWrapper); |
| const commonNode = range.commonAncestorContainer; | ||
| const commonElement = commonNode.nodeType === Node.ELEMENT_NODE ? commonNode : commonNode.parentElement; | ||
| if (commonElement === document.body && range.startContainer !== range.endContainer) { | ||
| spotFixDebugLog('`spotFixGetSelectedData` skip: Selection is too broad'); | ||
| return null; | ||
| } | ||
|
|
||
| // if (range.startContainer !== range.endContainer) { spotFixDebugLog('`spotFixGetSelectedData` skip by `Selection within several tags nodes`'); return null; } |
| // if (range.startContainer !== range.endContainer) { spotFixDebugLog('`spotFixGetSelectedData` skip by `Selection within several tags nodes`'); return null; } | ||
|
|
| display: flex; | ||
| flex-direction: column; | ||
| -moz-flex-direction: column; | ||
| min-height: 490px; |
| const insertText = marker.type === 'start' | ||
| ? spotfixHighlightOpen | ||
| : spotfixHighlightClose; | ||
| let tooltipTitleText = spots[0].isFixed |
| ? spotfixHighlightOpen | ||
| : spotfixHighlightClose; | ||
| let tooltipTitleText = spots[0].isFixed | ||
| ? `This issue already fixed.` |
| } catch (error) { | ||
| spotFixDebugLog('Error updating element content: ' + error); | ||
| } |
| localStorage.setItem('spotfix-tasks-notifications-count', `${notificationsCount}`); | ||
| } else { | ||
| notificationsCount = localStorage.getItem('spotfix-tasks-notifications-count'); |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 7 changed files in this pull request and generated 13 comments.
Comments suppressed due to low confidence (1)
js/src/selections.js:231
- Indentation of the
caseclauses (lines 213–227) was changed to a shallower level than the surroundingswitchbody, whiledefault:(line 229) was left at the original deeper indentation. The mixed indentation makes the block hard to read and inconsistent.
switch (selectionType) {
case 'image':
this.spotFixHighlightImageElement(element);
break;
case 'element':
this.spotFixHighlightNestedElement(element);
break;
case 'text':
const uniqueSpot = [spots[0]];
try {
this.spotFixHighlightTextInElement(element, uniqueSpot, widgetInstance);
} catch (e) {
}
break;
default:
spotFixDebugLog('Unknown selection type: ' + selectionType);
}
| case 'text': | ||
| const uniqueSpot = [spots[0]]; | ||
| try { | ||
| this.spotFixHighlightTextInElement(element, uniqueSpot, widgetInstance); | ||
| } catch (e) { | ||
| } | ||
| break; | ||
|
|
||
| default: | ||
| spotFixDebugLog('Unknown selection type: ' + selectionType); |
| const uniqueSpot = [spots[0]]; | ||
| try { | ||
| this.spotFixHighlightTextInElement(element, uniqueSpot, widgetInstance); | ||
| } catch (e) { |
| const uniqueSpot = [spots[0]]; | ||
| try { | ||
| this.spotFixHighlightTextInElement(element, uniqueSpot, widgetInstance); |
| // if (range.startContainer !== range.endContainer) { spotFixDebugLog('`spotFixGetSelectedData` skip by `Selection within several tags nodes`'); return null; } | ||
|
|
| const tooltipHtml = ` | ||
| <div class="doboard_task_widget-text_selection_tooltip_element"> | ||
| <span class="doboard_task_widget-text_selection_tooltip_icon"></span> | ||
| <span> | ||
| <div>${tooltipTitleText}</div> | ||
| <div>You can see history <span class="doboard_task_widget-see-task doboard_task_widget-see-task__task-id-${spots[0].taskId}">Here</span></div> | ||
| </span> | ||
| </div> | ||
| `; | ||
|
|
||
| validRanges.forEach(rangeData => { | ||
| const { startPos, endPos } = rangeData; | ||
|
|
||
| const highlightWrapper = document.createElement('span'); | ||
| highlightWrapper.className = 'doboard_task_widget-text_selection'; | ||
|
|
||
| const tooltipSpan = document.createElement('span'); | ||
| tooltipSpan.className = 'doboard_task_widget-text_selection_tooltip'; | ||
|
|
||
| // Convert block-level divs to inline-block spans to prevent list breakages | ||
| const safeTooltipHtml = tooltipHtml | ||
| .replace(/<div/g, '<span style="display:block"') | ||
| .replace(/<\/div>/g, '</span>'); | ||
| tooltipSpan.innerHTML = safeTooltipHtml; | ||
|
|
||
| highlightWrapper.appendChild(tooltipSpan); | ||
|
|
||
| const range = document.createRange(); | ||
| const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null, false); | ||
|
|
||
| let node; | ||
| let currentPos = 0; | ||
| let startNode = null, startOffset = 0; | ||
| let endNode = null, endOffset = 0; | ||
|
|
| notificationsCount = await getNotificationsDoboard(this.params.projectToken, sessionId, this.params.accountId, this.params.projectId); | ||
| notificationsCount = [...new Map(notificationsCount.map((item) => [item.task_id, item])).values()]; |
| if (finishedSpotsListHeader?.classList?.contains('expanded')) { | ||
| spotFixHighlightElements(spotsToBeHighlighted, this); | ||
| } else { | ||
| spotFixRemoveHighlights(); | ||
| spotFixHighlightElements(spotsToBeHighlighted.filter(item => !item.isFixed), this); | ||
| } |
| localStorage.setItem('spotfix-tasks-notifications-count', `${notificationsCount}`); | ||
| } else { | ||
| notificationsCount = localStorage.getItem('spotfix-tasks-notifications-count'); | ||
| } |
| if ( taskCountElement && +notificationsCount ) { | ||
| taskCountElement.innerText = ksesFilter(notificationsCount); | ||
| taskCountElement.classList.remove('hidden'); | ||
| if (localStorage.getItem('horizontalPosition') === 'left' || window.SpotfixWidgetConfig?.horizontalPosition === 'left') taskCountElement.style.left = '10px'; |
| display: flex; | ||
| flex-direction: column; | ||
| -moz-flex-direction: column; | ||
| min-height: 490px; |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 7 changed files in this pull request and generated 7 comments.
Comments suppressed due to low confidence (4)
js/src/selections.js:269
- Replacing the LI/OL/UL element with a plain text node permanently destroys the page's list structure (markers, nesting, sibling list items). When
spotFixHighlightTextInElementis called for a<li>whosenodePathresolves to that element, the entire list item — including any unrelated children/markup — is collapsed into a flat text node before highlighting. This mutation is not reversible byspotFixRemoveHighlights, will visibly alter the host page, and will break subsequent re-highlight passes (e.g. when toggling the "finished tasks" panel) because the original element no longer exists in the DOM. Consider walking the inner text nodes (as theelsebranch already does) instead of replacing the list element.
const listTags = ['LI', 'OL', 'UL'];
if (listTags.includes(element.tagName)) {
const textNode = document.createTextNode(element.textContent);
element.parentNode.replaceChild(textNode, element);
element = textNode;
}
js/src/selections.js:226
- The
catch (e) {}swallows every error fromspotFixHighlightTextInElementwithout even callingspotFixDebugLog. Other catches in this file consistently log viaspotFixDebugLog, so silent failures here will make highlight bugs very hard to diagnose. At minimum log the error.
try {
this.spotFixHighlightTextInElement(element, uniqueSpot, widgetInstance);
} catch (e) {
}
js/src/selections.js:227
- Declaring
const uniqueSpotdirectly inside acaseclause without a block scope is a known footgun (lexical declarations leak to sibling case clauses and triggerno-case-declarations). Wrap the body of this case in{ ... }to avoid hoisting/TDZ issues if more cases are added.
case 'text':
const uniqueSpot = [spots[0]];
try {
this.spotFixHighlightTextInElement(element, uniqueSpot, widgetInstance);
} catch (e) {
}
break;
js/src/selections.js:70
- Leaving the old guard as a commented-out line adds noise without any explanation. Either delete it or add a comment explaining why the broader-selection check was relaxed so future readers understand the intent.
// if (range.startContainer !== range.endContainer) { spotFixDebugLog('`spotFixGetSelectedData` skip by `Selection within several tags nodes`'); return null; }
| const uniqueSpot = [spots[0]]; | ||
| try { | ||
| this.spotFixHighlightTextInElement(element, uniqueSpot, widgetInstance); |
| if (finishedSpotsListHeader?.classList?.contains('expanded')) { | ||
| spotFixHighlightElements(spotsToBeHighlighted, this); | ||
| } else { | ||
| spotFixRemoveHighlights(); | ||
| spotFixHighlightElements(spotsToBeHighlighted.filter(item => !item.isFixed), this); | ||
| } | ||
| const headerSpan = document.querySelector('.doboard_task_widget-header span'); | ||
| if (headerSpan) { | ||
| headerSpan.innerHTML = ksesFilter('All spots ' + getIssuesCounterString(this.savedIssuesQuantityOnPage, this.savedIssuesQuantityAll)); |
| tasksCount = filteredTasks.length; | ||
|
|
||
| let notificationsCount = 0; | ||
|
|
||
| if(!this.nonRequesting) { | ||
| const activeTasksIds = filteredTasks.map(item => item.taskId); | ||
| notificationsCount = await getNotificationsDoboard(this.params.projectToken, sessionId, this.params.accountId, this.params.projectId); | ||
| notificationsCount = [...new Map(notificationsCount.map((item) => [item.task_id, item])).values()]; | ||
| notificationsCount = notificationsCount.filter((item) => activeTasksIds.includes(item.task_id)).length; | ||
| localStorage.setItem('spotfix-tasks-notifications-count', `${notificationsCount}`); | ||
| } else { | ||
| notificationsCount = localStorage.getItem('spotfix-tasks-notifications-count'); | ||
| } | ||
|
|
||
| const taskCountElement = document.getElementById('doboard_task_widget-task_count'); | ||
| if ( taskCountElement && +tasksCount ) { | ||
| taskCountElement.innerText = ksesFilter(tasksCount); | ||
| if ( taskCountElement && +notificationsCount ) { | ||
| taskCountElement.innerText = ksesFilter(notificationsCount); |
| localStorage.setItem('spotfix-tasks-notifications-count', `${notificationsCount}`); | ||
| } else { | ||
| notificationsCount = localStorage.getItem('spotfix-tasks-notifications-count'); | ||
| } |
| const commonNode = range.commonAncestorContainer; | ||
| const commonElement = commonNode.nodeType === Node.ELEMENT_NODE ? commonNode : commonNode.parentElement; | ||
| if (commonElement === document.body && range.startContainer !== range.endContainer) { | ||
| spotFixDebugLog('`spotFixGetSelectedData` skip: Selection is too broad'); | ||
| return null; | ||
| } | ||
|
|
||
| // if (range.startContainer !== range.endContainer) { spotFixDebugLog('`spotFixGetSelectedData` skip by `Selection within several tags nodes`'); return null; } |
|
|
||
| if(!this.nonRequesting) { | ||
| const activeTasksIds = filteredTasks.map(item => item.taskId); | ||
| notificationsCount = await getNotificationsDoboard(this.params.projectToken, sessionId, this.params.accountId, this.params.projectId); |
| display: flex; | ||
| flex-direction: column; | ||
| -moz-flex-direction: column; | ||
| min-height: 490px; |
https://app.doboard.com/1/task/49256
https://app.doboard.com/1/task/49187
https://app.doboard.com/1/task/48689