1. 程式人生 > 其它 >劃詞高亮實現 (更新中)

劃詞高亮實現 (更新中)

技術標籤:jsdomjavascript

劃詞高亮

在這裡插入圖片描述

資料結構

HighlightRange

class HighlightRange {
    start: DomNode; // Range 開始的資訊
    end: DomNode; // Range 結束的資訊
    text: string; // 文字內容
    id: string;  // 唯一標識
}

DomNode

export interface DomNode {
    $node: Node;  // 節點
    offset: number;  // 節點中的偏移量
}

選區高亮

獲取Range物件

  • 獲取當前選取的DOM Range物件
export const getDomRange = (): Range => {
    const selection = window.getSelection();
    // 選區的起始點和終點是否在同一個位置
    if (selection.isCollapsed) {
        console.debug('no text selected');
        return null;
    }
    // 選取的range集合。但是規範要求選擇的內容始終(僅)具有一個範圍。
    return selection.getRangeAt(0);
};

生成HighlightRange 物件

  • Range物件 + id -> HighlightRange 物件
    static fromSelection(idHook: Hook) {
        const range = getDomRange(); //當前選取的DOM Range物件
        if (!range) {
            return null;
        }

        const start: DomNode = {
            $node: range.startContainer,
            offset: range.startOffset
        }
; const end: DomNode = { $node: range.endContainer, offset: range.endOffset } const text = range.toString(); const id = uuid(); //當前的時間戳 return new HighlightRange(start, end, text, id); }

獲取區域內所有節點

  • 獲取首尾區域包含的所有節點
// $root:Document | HTMLElement  可劃詞高亮範圍的節點
let $selectedNodes = getSelectedNodes($root, range.start, range.end);

const getSelectedNodes = (
    $root: HTMLElement | Document,
    start: DomNode,
    end: DomNode,
): SelectedNode[] => {
    const $startNode = start.$node;
    const $endNode = end.$node;
    const startOffset = start.offset;
    const endOffset = end.offset;

    // split current node when the start-node and end-node is the same
    if ($startNode === $endNode && $startNode instanceof Text) {
        return getNodesIfSameStartEnd($startNode, startOffset, endOffset);
    }

    const nodeStack: Array<HTMLElement | Document | ChildNode | Text> = [$root];
    const selectedNodes: SelectedNode[] = [];

    let withinSelectedRange = false;
    let curNode: Node = null;
    while (curNode = nodeStack.pop()) {

        const children = curNode.childNodes;
        for (let i = children.length - 1; i >= 0; i--) {
            nodeStack.push(children[i]);
        }

        // only collect text nodes
        if (curNode === $startNode) {
            if (curNode.nodeType === 3) {
                (curNode as Text).splitText(startOffset);
                const node = curNode.nextSibling as Text;
                selectedNodes.push({
                    $node: node,
                    type: SelectedNodeType.text,
                    splitType: SplitType.head
                });

            }
            // meet the start-node (begin to traverse)
            withinSelectedRange = true;
        }
        else if (curNode === $endNode) {
            if (curNode.nodeType === 3) {
                const node = (curNode as Text);
                node.splitText(endOffset);
                selectedNodes.push({
                    $node: node,
                    type: SelectedNodeType.text,
                    splitType: SplitType.tail
                });
            }
            // meet the end-node
            break;
        }
        // handle text nodes between the range
        else if (withinSelectedRange && curNode.nodeType === 3) {
            selectedNodes.push({
                $node: curNode as Text,
                type: SelectedNodeType.text,
                splitType: SplitType.none
            });
        }
    }
    return selectedNodes;
};

節點包裝高亮處理

其原理就是在選區外包裹上定義的標籤,利用className,定義高亮樣式

  • 對所有的節點進行包裝處理
$selectedNodes.map(n => {
  let $node = wrapHighlight(n, range, className, this.options.wrapTag);
  return $node;
});

/**
 * wrap a dom node with highlight wrapper
 * 
 * Because of supporting the highlight-overlapping,
 * Highlighter can't just wrap all nodes in a simple way.
 * There are three types:
 *  - wrapping a whole new node (without any wrapper)
 *  - wrapping part of the node
 *  - wrapping the whole wrapped node
 */
export const wrapHighlight = (
    selected: SelectedNode,
    range: HighlightRange,
    className: string | Array<string>,
    wrapTag: string
): HTMLElement => {
    const $parent = selected.$node.parentNode as HTMLElement;
    const $prev = selected.$node.previousSibling;
    const $next = selected.$node.nextSibling;

    let $wrap: HTMLElement;
    // text node, not in a highlight wrapper -> should be wrapped in a highlight wrapper
    if (!isHighlightWrapNode($parent)) {
        $wrap = wrapNewNode(selected, range, className, wrapTag);
    }
    // text node, in a highlight wrap -> should split the existing highlight wrapper
    else if (isHighlightWrapNode($parent) && (!isNodeEmpty($prev) || !isNodeEmpty($next))) {
        $wrap = wrapPartialNode(selected, range, className, wrapTag);
    }
    // completely overlap (with a highlight wrap) -> only add extra id info
    else {
        $wrap = wrapOverlapNode(selected, range, className);
    }
    return $wrap;
};

含有3種情況,這裡展示一種簡單情況的包裝處理

  • 外層沒有包裹highlight wrapper,則直接在外層包裹highlight wrapper
function wrapNewNode(
    selected: SelectedNode,
    className: string | Array<string>,
    wrapTag: string
): HTMLElement {
    let $wrap: HTMLElement;
    $wrap = document.createElement(wrapTag);
    addClass($wrap, className);

    $wrap.appendChild(selected.$node.cloneNode(false));
    selected.$node.parentNode.replaceChild($wrap, selected.$node);
        
    return $wrap;
}

事件觸發

mouseUp時選區高亮

// $root:Document | HTMLElement  可劃詞高亮範圍的節點
addEventListener($root, 'mouseup', ()=>{  
    //...高亮處理
});