feat(ui): add search functionality to chat view (#1790)
Co-authored-by: Nahiyan Khan <nahiyan.khan@gmail.com>
This commit is contained in:
@@ -11,6 +11,7 @@ import { Card } from './ui/card';
|
||||
import { ScrollArea, ScrollAreaHandle } from './ui/scroll-area';
|
||||
import UserMessage from './UserMessage';
|
||||
import Splash from './Splash';
|
||||
import { SearchView } from './conversation/SearchView';
|
||||
import { DeepLinkModal } from './ui/DeepLinkModal';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import { useMessageStream } from '../hooks/useMessageStream';
|
||||
@@ -386,10 +387,10 @@ export default function ChatView({
|
||||
activities={botConfig?.activities || null}
|
||||
/>
|
||||
) : (
|
||||
<ScrollArea ref={scrollRef} className="flex-1" autoScroll>
|
||||
<div className="px-4">
|
||||
<ScrollArea ref={scrollRef} className="flex-1 px-4" autoScroll>
|
||||
<SearchView className="mt-[16px]" scrollAreaRef={scrollRef}>
|
||||
{filteredMessages.map((message, index) => (
|
||||
<div key={message.id || index} className="mt-[16px]">
|
||||
<div key={message.id || index} className="mt-[16px] message-content">
|
||||
{isUserMessage(message) ? (
|
||||
<UserMessage message={message} />
|
||||
) : (
|
||||
@@ -407,7 +408,7 @@ export default function ChatView({
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SearchView>
|
||||
{error && (
|
||||
<div className="flex flex-col items-center justify-center p-4">
|
||||
<div className="text-red-700 dark:text-red-300 bg-red-400/50 p-3 rounded-lg mb-2">
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import React, { useEffect, KeyboardEvent, useState } from 'react';
|
||||
import { Search as SearchIcon } from 'lucide-react';
|
||||
import { ArrowDown, ArrowUp, Close } from '../icons';
|
||||
|
||||
/**
|
||||
* Props for the SearchBar component
|
||||
*/
|
||||
interface SearchBarProps {
|
||||
/** Callback fired when search term or case sensitivity changes */
|
||||
onSearch: (term: string, caseSensitive: boolean) => void;
|
||||
/** Callback fired when the search bar is closed */
|
||||
onClose: () => void;
|
||||
/** Optional callback for navigating between search results */
|
||||
onNavigate?: (direction: 'next' | 'prev') => void;
|
||||
/** Current search results state */
|
||||
searchResults?: {
|
||||
count: number;
|
||||
currentIndex: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* SearchBar provides a search input with case-sensitive toggle and result navigation.
|
||||
* Features:
|
||||
* - Case-sensitive search toggle
|
||||
* - Result count display
|
||||
* - Navigation between results with arrows
|
||||
* - Keyboard shortcuts (↑/↓ for navigation, Esc to close)
|
||||
* - Smooth animations for enter/exit
|
||||
*/
|
||||
export const SearchBar: React.FC<SearchBarProps> = ({
|
||||
onSearch,
|
||||
onClose,
|
||||
onNavigate,
|
||||
searchResults,
|
||||
}) => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [caseSensitive, setCaseSensitive] = useState(false);
|
||||
const [isExiting, setIsExiting] = useState(false);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleSearch = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.target.value;
|
||||
setSearchTerm(value);
|
||||
onSearch(value, caseSensitive);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
onNavigate?.('prev');
|
||||
} else if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
onNavigate?.('next');
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleNavigate = (direction: 'next' | 'prev') => {
|
||||
onNavigate?.(direction);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const toggleCaseSensitive = () => {
|
||||
setCaseSensitive(!caseSensitive);
|
||||
onSearch(searchTerm, !caseSensitive);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setIsExiting(true);
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 150); // Match animation duration
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed top-[36px] left-0 right-0 bg-bgAppInverse text-textProminentInverse z-50 ${
|
||||
isExiting ? 'search-bar-exit' : 'search-bar-enter'
|
||||
}`}
|
||||
>
|
||||
<div className="flex w-full max-w-5xl mx-auto">
|
||||
<div className="relative flex flex-1 items-center h-full">
|
||||
<SearchIcon className="h-4 w-4 text-textSubtleInverse absolute left-3" />
|
||||
<div className="w-full">
|
||||
<input
|
||||
ref={inputRef}
|
||||
id="search-input"
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={handleSearch}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search conversation..."
|
||||
className="w-full text-sm pl-9 pr-10 py-3 bg-bgAppInverse
|
||||
placeholder:text-textSubtleInverse focus:outline-none
|
||||
active:border-borderProminent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="absolute right-3 flex h-full items-center justify-center text-sm text-textStandardInverse">
|
||||
{searchResults && searchResults.count}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center h-auto px-4 gap-2">
|
||||
<button
|
||||
onClick={toggleCaseSensitive}
|
||||
className={`flex items-center justify-center case-sensitive-btn px-2 ${
|
||||
caseSensitive
|
||||
? 'text-textStandardInverse bg-bgHover'
|
||||
: 'text-textSubtleInverse hover:text-textStandardInverse'
|
||||
}`}
|
||||
title="Case Sensitive"
|
||||
>
|
||||
<span className="text-md font-medium">Aa</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleNavigate('prev')}
|
||||
className={`p-1 text-textSubtleInverse ${!searchResults || searchResults.count === 0 ? '' : 'hover:text-textStandardInverse'}`}
|
||||
title="Previous (↑)"
|
||||
disabled={!searchResults || searchResults.count === 0}
|
||||
>
|
||||
<ArrowUp className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleNavigate('next')}
|
||||
className={`p-1 text-textSubtleInverse ${!searchResults || searchResults.count === 0 ? '' : 'hover:text-textStandardInverse'}`}
|
||||
title="Next (↓)"
|
||||
disabled={!searchResults || searchResults.count === 0}
|
||||
>
|
||||
<ArrowDown className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-1 text-textSubtleInverse hover:text-textStandardInverse"
|
||||
title="Close (Esc)"
|
||||
>
|
||||
<Close className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
import React, { useState, useEffect, PropsWithChildren } from 'react';
|
||||
import { SearchBar } from './SearchBar';
|
||||
import { SearchHighlighter } from '../../utils/searchHighlighter';
|
||||
import { ScrollAreaHandle } from '../ui/scroll-area';
|
||||
import '../../styles/search.css';
|
||||
|
||||
/**
|
||||
* Props for the SearchView component
|
||||
*/
|
||||
interface SearchViewProps {
|
||||
/** Optional CSS class name */
|
||||
className?: string;
|
||||
/** Reference to the scroll area for navigation */
|
||||
scrollAreaRef?: React.RefObject<ScrollAreaHandle>;
|
||||
}
|
||||
|
||||
/**
|
||||
* SearchView wraps content in a searchable container with a search bar that appears
|
||||
* when Cmd/Ctrl+F is pressed. Supports case-sensitive search and result navigation.
|
||||
*/
|
||||
export const SearchView: React.FC<PropsWithChildren<SearchViewProps>> = ({
|
||||
className = '',
|
||||
children,
|
||||
scrollAreaRef,
|
||||
}) => {
|
||||
const [isSearchVisible, setIsSearchVisible] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<{
|
||||
currentIndex: number;
|
||||
count: number;
|
||||
} | null>(null);
|
||||
|
||||
const highlighterRef = React.useRef<SearchHighlighter | null>(null);
|
||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'f') {
|
||||
e.preventDefault();
|
||||
setIsSearchVisible(true);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSearch = (term: string, caseSensitive: boolean) => {
|
||||
if (!term) {
|
||||
setSearchResults(null);
|
||||
clearHighlights();
|
||||
return;
|
||||
}
|
||||
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
if (!highlighterRef.current) {
|
||||
highlighterRef.current = new SearchHighlighter(container);
|
||||
}
|
||||
|
||||
highlighterRef.current.clearHighlights();
|
||||
highlighterRef.current.highlight(term, caseSensitive);
|
||||
|
||||
const marks = container.querySelectorAll('mark');
|
||||
const count = marks.length;
|
||||
|
||||
if (count > 0) {
|
||||
setSearchResults({
|
||||
currentIndex: 1,
|
||||
count: count,
|
||||
});
|
||||
scrollToMatch(0);
|
||||
} else {
|
||||
setSearchResults(null);
|
||||
}
|
||||
};
|
||||
|
||||
const navigateResults = (direction: 'next' | 'prev') => {
|
||||
if (!searchResults || searchResults.count === 0) return;
|
||||
|
||||
let newIndex: number;
|
||||
const currentIdx = searchResults.currentIndex - 1; // Convert to 0-based
|
||||
|
||||
if (direction === 'next') {
|
||||
newIndex = (currentIdx + 1) % searchResults.count;
|
||||
} else {
|
||||
newIndex = (currentIdx - 1 + searchResults.count) % searchResults.count;
|
||||
}
|
||||
|
||||
setSearchResults({
|
||||
...searchResults,
|
||||
currentIndex: newIndex + 1,
|
||||
});
|
||||
|
||||
scrollToMatch(newIndex);
|
||||
};
|
||||
|
||||
const scrollToMatch = (index: number) => {
|
||||
if (!containerRef.current || !scrollAreaRef?.current) return;
|
||||
|
||||
const marks = containerRef.current.querySelectorAll('mark');
|
||||
const mark = marks[index] as HTMLElement;
|
||||
|
||||
if (mark) {
|
||||
// Update highlight
|
||||
marks.forEach((m) => m.classList.remove('current'));
|
||||
mark.classList.add('current');
|
||||
|
||||
// Calculate position to center the mark in the viewport
|
||||
const markRect = mark.getBoundingClientRect();
|
||||
const viewportRect = mark
|
||||
.closest('[data-radix-scroll-area-viewport]')
|
||||
?.getBoundingClientRect();
|
||||
|
||||
if (viewportRect) {
|
||||
const targetPosition = mark.offsetTop - viewportRect.height / 2 + markRect.height / 2;
|
||||
scrollAreaRef.current.scrollToPosition({
|
||||
top: targetPosition,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const clearHighlights = () => {
|
||||
if (highlighterRef.current) {
|
||||
highlighterRef.current.clearHighlights();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseSearch = () => {
|
||||
setIsSearchVisible(false);
|
||||
setSearchResults(null);
|
||||
clearHighlights();
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={`search-container ${className}`}>
|
||||
{isSearchVisible && (
|
||||
<SearchBar
|
||||
onSearch={handleSearch}
|
||||
onClose={handleCloseSearch}
|
||||
onNavigate={navigateResults}
|
||||
searchResults={searchResults}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`transition-transform ${isSearchVisible ? 'translate-y-[52px] pb-[52px]' : 'translate-y-0'}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function ArrowDown({ className = '' }) {
|
||||
return (
|
||||
<svg
|
||||
width="1.5rem"
|
||||
height="1.5rem"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M12 5.5a1 1 0 0 1 1 1v9.394l2.757-3.063a1 1 0 1 1 1.486 1.338l-4.5 5a1 1 0 0 1-1.486 0l-4.5-5a1 1 0 0 1 1.486-1.338L11 15.894V6.5a1 1 0 0 1 1-1Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function ArrowUp({ className = '' }) {
|
||||
return (
|
||||
<svg
|
||||
width="1.5rem"
|
||||
height="1.5rem"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M12 19.5a1 1 0 0 0 1-1V9.106l2.757 3.063a1 1 0 1 0 1.486-1.338l-4.5-5a1 1 0 0 0-1.486 0l-4.5 5a1 1 0 0 0 1.486 1.338L11 9.106V18.5a1 1 0 0 0 1 1Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import ArrowDown from './ArrowDown';
|
||||
import ArrowUp from './ArrowUp';
|
||||
import Attach from './Attach';
|
||||
import Back from './Back';
|
||||
import ChatSmart from './ChatSmart';
|
||||
@@ -19,6 +21,8 @@ import Time from './Time';
|
||||
import { Gear } from './Gear';
|
||||
|
||||
export {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Attach,
|
||||
Back,
|
||||
ChatSmart,
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import * as React from 'react';
|
||||
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
|
||||
|
||||
type ScrollBehavior = 'auto' | 'smooth';
|
||||
|
||||
import { cn } from '../../utils';
|
||||
|
||||
export interface ScrollAreaHandle {
|
||||
scrollToBottom: () => void;
|
||||
scrollToPosition: (options: { top: number; behavior?: ScrollBehavior }) => void;
|
||||
}
|
||||
|
||||
interface ScrollAreaProps extends React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> {
|
||||
@@ -30,13 +33,26 @@ const ScrollArea = React.forwardRef<ScrollAreaHandle, ScrollAreaProps>(
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Expose the scrollToBottom method to parent components
|
||||
const scrollToPosition = React.useCallback(
|
||||
({ top, behavior = 'smooth' }: { top: number; behavior?: ScrollBehavior }) => {
|
||||
if (viewportRef.current) {
|
||||
viewportRef.current.scrollTo({
|
||||
top,
|
||||
behavior,
|
||||
});
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Expose the scroll methods to parent components
|
||||
React.useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
scrollToBottom,
|
||||
scrollToPosition,
|
||||
}),
|
||||
[scrollToBottom]
|
||||
[scrollToBottom, scrollToPosition]
|
||||
);
|
||||
|
||||
// Handle scroll events to update isFollowing state
|
||||
|
||||
@@ -117,7 +117,9 @@
|
||||
--text-placeholder: var(--grey-60);
|
||||
--text-prominent: var(--grey-10);
|
||||
--text-standard: var(--grey-20);
|
||||
--text-standard-inverse: var(--dark-grey-90);
|
||||
--text-subtle: var(--grey-50);
|
||||
--text-subtle-inverse: var(--dark-grey-60);
|
||||
--text-prominent-inverse: var(--constant-white);
|
||||
|
||||
&.dark {
|
||||
@@ -144,7 +146,9 @@
|
||||
--text-placeholder: var(--dark-grey-45);
|
||||
--text-prominent: var(--constant-white);
|
||||
--text-standard: var(--dark-grey-90);
|
||||
--text-standard-inverse: var(--grey-20);
|
||||
--text-subtle: var(--dark-grey-60);
|
||||
--text-subtle-inverse: var(--grey-50);
|
||||
--text-prominent-inverse: var(--grey-20);
|
||||
}
|
||||
/* end arcade colors */
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/* Search highlight styles */
|
||||
mark {
|
||||
background-color: rgba(255, 213, 0, 0.5);
|
||||
color: var(--text-standard);
|
||||
padding: 0;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
mark.current {
|
||||
background-color: rgba(252, 213, 3, 0.6);
|
||||
box-shadow: inset 0 0 0 1px var(--text-prominent);
|
||||
}
|
||||
|
||||
@keyframes expandDown {
|
||||
from {
|
||||
max-height: 0;
|
||||
}
|
||||
to {
|
||||
max-height: var(--search-bar-height);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes collapseUp {
|
||||
from {
|
||||
max-height: var(--search-bar-height);
|
||||
}
|
||||
to {
|
||||
max-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.search-bar-enter {
|
||||
--search-bar-height: 72px;
|
||||
animation: expandDown 0.15s ease-out forwards;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.search-bar-exit {
|
||||
--search-bar-height: 72px;
|
||||
animation: collapseUp 0.15s ease-out forwards;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/* eslint-disable no-cond-assign */
|
||||
/**
|
||||
* Utility class for highlighting search matches in text content.
|
||||
* Supports case-sensitive search and maintains a "current" highlight state.
|
||||
*/
|
||||
export class SearchHighlighter {
|
||||
private readonly container: HTMLElement;
|
||||
|
||||
/**
|
||||
* Creates a new SearchHighlighter instance.
|
||||
* @param container - The root HTML element to search within
|
||||
*/
|
||||
constructor(container: HTMLElement) {
|
||||
this.container = container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlights all occurrences of a search term within the container.
|
||||
* @param term - The text to search for
|
||||
* @param caseSensitive - Whether to perform a case-sensitive search
|
||||
*/
|
||||
highlight(term: string, caseSensitive = false): void {
|
||||
this.clearHighlights();
|
||||
|
||||
if (!term.trim()) return;
|
||||
|
||||
const walker = document.createTreeWalker(this.container, window.NodeFilter.SHOW_TEXT, {
|
||||
acceptNode: (node: Node): number => {
|
||||
// Check if this node or any of its ancestors have the excluded classes
|
||||
let element = node.parentElement;
|
||||
while (element) {
|
||||
if (
|
||||
element.classList.contains('search-input') ||
|
||||
element.classList.contains('search-results') ||
|
||||
element.classList.contains('case-sensitive-btn')
|
||||
) {
|
||||
return window.NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
element = element.parentElement;
|
||||
}
|
||||
return window.NodeFilter.FILTER_ACCEPT;
|
||||
},
|
||||
});
|
||||
|
||||
const matches: Node[] = [];
|
||||
let node: Node | null;
|
||||
|
||||
// Find all text nodes containing the search term
|
||||
while ((node = walker.nextNode())) {
|
||||
const nodeText = node.textContent || '';
|
||||
const searchText = caseSensitive ? nodeText : nodeText.toLowerCase();
|
||||
const searchTerm = caseSensitive ? term : term.toLowerCase();
|
||||
|
||||
if (searchText.includes(searchTerm)) {
|
||||
matches.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
// Highlight matches
|
||||
matches.forEach((textNode) => {
|
||||
const text = textNode.textContent || '';
|
||||
const searchTerm = caseSensitive ? term : term.toLowerCase();
|
||||
const regex = new RegExp(
|
||||
searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
|
||||
caseSensitive ? 'g' : 'gi'
|
||||
);
|
||||
const fragment = document.createDocumentFragment();
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
// Add text before the match
|
||||
if (match.index > lastIndex) {
|
||||
fragment.appendChild(document.createTextNode(text.slice(lastIndex, match.index)));
|
||||
}
|
||||
|
||||
// Add highlighted match
|
||||
const mark = document.createElement('mark');
|
||||
mark.textContent = match[0]; // Use the actual matched text to preserve case
|
||||
fragment.appendChild(mark);
|
||||
|
||||
lastIndex = regex.lastIndex;
|
||||
}
|
||||
|
||||
// Add remaining text
|
||||
if (lastIndex < text.length) {
|
||||
fragment.appendChild(document.createTextNode(text.slice(lastIndex)));
|
||||
}
|
||||
|
||||
textNode.parentNode?.replaceChild(fragment, textNode);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all search highlights from the container
|
||||
*/
|
||||
clearHighlights(): void {
|
||||
const marks = this.container.getElementsByTagName('mark');
|
||||
while (marks.length > 0) {
|
||||
const mark = marks[0];
|
||||
const parent = mark.parentNode;
|
||||
if (parent) {
|
||||
parent.replaceChild(document.createTextNode(mark.textContent || ''), mark);
|
||||
parent.normalize();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user