划词高亮实现

划词高亮

划词高亮实现

数据结构

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', ()=>{  
    //...高亮处理
});
上一篇:树莓派WIFI


下一篇:安卓第五周