前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >为了秋招,我开发了一款页面元素高亮插件

为了秋招,我开发了一款页面元素高亮插件

作者头像
源心锁
发布2022-08-12 11:49:30
1.1K0
发布2022-08-12 11:49:30
举报
文章被收录于专栏:前端魔法指南前端魔法指南
为了秋招,我开发了一款页面元素高亮插件
为了秋招,我开发了一款页面元素高亮插件

我正在参加「创意开发 投稿大赛」详情请看:掘金创意开发大赛来了!

为了秋招,我开发了这个页面元素高亮插件

1 前言

大家好,我是心锁,一枚23届准毕业生。

随着七八月的到来,大小厂们都开始了秋招提前批,在这个背景下,写出一份优秀的简历无疑是面试邀请的敲门砖。

所以撒,基于这个想法,我在visiky大佬开源基于React+Ts的https://github.com/visiky/resume简历生成器的基础上开发了一款简历高亮(页面元素高亮)插件。

3FEEA7F7D362DFF7489B5CD937294085
3FEEA7F7D362DFF7489B5CD937294085

2 介绍

先简单看看实现效果我们再继续介绍

image.png
image.png
image.png
image.png

2.1 插件背景

作为一名面试官,我希望我可以第一眼扫过简历就得到被面试者的亮点信息。

经过换位思考,我认为应该给自己的简历做高亮处理,就像现在这样

2.2 插件预期实现效果

预期中,插件不可能只做高亮/标注这一个工作,我希望实现以下内容:

  • 选择页面的文本内容右键打开菜单可以进行标注/高亮操作
  • 操作可以复现。即当我再次打开页面时可以保证页面维持相同的效果,这一点最好可以输出成配置方便导入导出
  • 支持撤销/反撤销,要达成这一点意味着我们需要确保高亮链路可以复原

2.3 插件实现重点难点

那么为了实现以上内容,我们无疑可以提炼出相关的重点难点,同时这也将是你我可以从本文学习到的东西。

  1. 如何友好的实现右键打开菜单
  2. 选中的页面内容不一定是一个标签节点,这样子如何实现样式调整?
  3. 如何确保操作链路可以双向工作
3C717BA45856AD3B9EF1887255274A8C
3C717BA45856AD3B9EF1887255274A8C

3 实现思路

实现方案上,我选择的是让用户选中文本后右键弹出选项菜单,从而允许用户进行标注等一系列的工作。

那么在此基础上,我们面临的第一个问题就是,如何友好的实现右键打开菜单

3.1 右键菜单

右键菜单,理解中应该是一个弹出层。

那么语义上,实际中,右键菜单都应该以一个独立节点的方式插入到页面中。

image.png
image.png
3.1.1 动态插入DOM节点到页面上

在React中,想将一个组件插入页面中,我们只能借助原生方法,否则我们只能在ReactDOM.render选中的节点下操作。

所以第一步,提炼一个useAppendRootNode的自定义hook,方便进行节点插入。

代码语言:javascript
复制
import React, { useEffect, useState, useRef } from 'react';
import { useAppendRootNode } from '../useAppendRootNode';
import { throttle } from 'lodash-es';

export const useRightClickMenu = (
  menu: null | JSX.Element,
  container: HTMLElement = document.body
) => {
  const [contextMenu, setContextMenu] = useState({
    x: 0,
    y: 0,
    visible: true,
  });
  const memoAttr = useRef(null);
  const ref = useRef(null);

  useAppendRootNode('right-click-context-menu', () => (
    <div
      className="absolute"
      ref={ref}
      style={{
        position: 'absolute',
        left: contextMenu.x,
        top: contextMenu.y,
        display: contextMenu.visible ? 'flex' : 'none',
        zIndex: 9999999,
        visibility: memoAttr.current === null ? 'hidden' : 'visible',
      }}
    >
      {menu}
    </div>
  ));

  useEffect(() => {
    if (!ref.current) return;
    const { clientHeight, clientWidth } = ref.current;
    memoAttr.current = {
      clientHeight,
      clientWidth,
    };
    setContextMenu({
      x: 0,
      y: 0,
      visible: false,
    });
  }, [ref.current]);

  useEffect(() => {
    const handleContextMenuClick = (e: PointerEvent) => {
      e.preventDefault();
      const { clientX, clientY } = e;

      const { clientHeight, clientWidth } = memoAttr.current;
      const {
        scrollHeight: windowHeight,
        scrollWidth: windowWidth,
        scrollTop,
        scrollLeft,
      } = container;

      if (clientHeight > windowHeight || clientWidth > windowWidth) {
        throw new Error('the menu is longer than the browser');
      }

      const x =
        (clientWidth + clientX + scrollLeft > windowWidth
          ? clientX - clientWidth
          : clientX) + scrollLeft;

      const y =
        (clientHeight + clientY + scrollTop > windowHeight
          ? clientY - clientHeight
          : clientY) + scrollTop;
      setContextMenu({
        x,
        y,
        visible: true,
      });
    };
    const handleOutsideClick = (
      e: PointerEvent & { path: Array<HTMLElement> }
    ) => {
      if (e.path.includes(ref.current)) {
        return;
      }
      setContextMenu({
        ...contextMenu,
        visible: false,
      });
    };
    const handleThrottleOutSideClick = throttle(handleOutsideClick, 800);

    document.addEventListener('contextmenu', handleContextMenuClick);
    document.addEventListener('click', handleOutsideClick);
    document.addEventListener('scroll', handleThrottleOutSideClick);
    window.addEventListener('resize', handleThrottleOutSideClick);

    return () => {
      document.removeEventListener('contextmenu', handleContextMenuClick);
      document.removeEventListener('click', handleOutsideClick);
      document.removeEventListener('scroll', handleThrottleOutSideClick);
      window.removeEventListener('resize', handleThrottleOutSideClick);
    };
  });
  return [
    visible => {
      setContextMenu({
        ...contextMenu,
        visible,
      });
    },
  ];
};

export default useRightClickMenu;


看一下这份代码,主要是showdestory这两个方法。

#1 show()
image.png
image.png

我们要在页面上插入一个Root节点,第一步自然是判断这个节点是否已经存在,然后才通过createElement或者document.createElement的方法来获得一个HTMLElement元素。

image.png
image.png

同时需要注意,为了适配更多业务场景,这个hook也应当支持选择被插入的父节点。

image.png
image.png
#2 destory()

插入节点这种操作是一种副作用,我们同时需要定义一个销毁节点的方法,一方面可以在useEffect中清除副作用,一方面也方便提供给hook的使用者手动调用。

image.png
image.png
#3 副作用
image.png
image.png

最后一步是对上边两个方法对调用,同时注意我们需要通过ReactDOM.render的API将React组件渲染到刚才的创建的节点上。

image.png
image.png
  • 这里为什么不用传送门? 原因很简单,即便是使用ReactDOM.ceatePortals将节点渲染到其他DOM节点上,本质上仍和主干应用处于同一颗ReactTree
3.1.2 在页面上渲染右键菜单

理论上讲,渲染右键菜单并不麻烦。

麻烦的是我们如何确定菜单呈现的位置,如何模拟正常的操作菜单的交互

image.png
image.png
#1 处理边界情况
image.png
image.png
image.png
image.png

这里看着可能会模糊看一下这里,为什么我需要将ref.current的宽高赋值给memoAttr?

原因在于,我们的菜单组件,在display:none的时候是没有宽高的,我们需要在一开始便拿到组件的宽高,以便于在隐藏的时候仍可以做计算。

哈?那为什么不用visibility来控制显隐?这样既可以隐藏又可以得到宽高。

原因有两个:

  • visibility属性虽然会被继承,但是如果子元素设置visibility: visible会使得子元素显示,这无疑会给我们使用第三方组件时带来一定的心智负担。而display:none不会有这个困扰
  • visibility语义上只是看不见了,但是正常的菜单应该是消失,我比较认同符合语义的实现
#2 在正确的位置显示操作菜单

我们可以通过监听contextmenu事件来知悉用户右键试图打开操作菜单的行为。

代码语言:javascript
复制
    const handleContextMenuClick = (e: PointerEvent) => {
      e.preventDefault();
      const { clientX, clientY } = e;

      const { clientHeight, clientWidth } = memoAttr.current;
      const {
        scrollHeight: windowHeight,
        scrollWidth: windowWidth,
        scrollTop,
        scrollLeft,
      } = container;

      if (clientHeight > windowHeight || clientWidth > windowWidth) {
        throw new Error('the menu is longer than the browser');
      }

      const x =
        (clientWidth + clientX + scrollLeft > windowWidth
          ? clientX - clientWidth
          : clientX) + scrollLeft;

      const y =
        (clientHeight + clientY + scrollTop > windowHeight
          ? clientY - clientHeight
          : clientY) + scrollTop;
      setContextMenu({
        x,
        y,
        visible: true,
      });
    };

我们首先看看如何计算操作菜单的位置,这里要求我们知道这些变量的含义:

  • scrollHeight: windowHeight 页面(容器)高度,注意不是可视高度,是页面总高度
  • scrollWidth: windowWidth 页面(容器)宽度,注意不是可视宽度,是页面总宽度
  • clientHeight, clientWidth 操作菜单的实际宽高
  • clientX, clientY 页面点击位置,可以用来定位操作菜单位置
  • scrollTop, scrollLeft 当前容器的滑动距离,用来解决弹出层在滑动场景的定位

那么其实核心代码特别简单:

代码语言:javascript
复制
      const x =
        (clientWidth + clientX + scrollLeft > windowWidth
          ? clientX - clientWidth
          : clientX) + scrollLeft;

      const y =
        (clientHeight + clientY + scrollTop > windowHeight
          ? clientY - clientHeight
          : clientY) + scrollTop;

思路是计算菜单实际宽度+页面点击X坐标+已滑动x轴位置是否大于容器宽度,是的话就反向显示操作菜单,否则正常显示。

同理,计算y坐标也是同样的道理。

#3 如何关闭菜单

MAC的右键菜单有且只有一种关闭方式,那就是点击菜单可选区关闭和点击页面其他地方关闭。此时禁用窗口拖动、滑动。而我们实现中为了方便,对于禁用窗口拖动、滑动采取的方案是在这种情况下直接关闭菜单。

image.png
image.png

注意对于sizescroll这两种事件还是加个节流

image.png
image.png

3.2 替换页面元素

这里的方案是通过window.getSelection()来获得选区,如图是一个Selection对象,具体方法可以搜索一下MDN

image.png
image.png

然后就是目前替换方案实际上还有瑕疵,在处理多节点时存在一定问题,所以我这里其实还有一套待实现的方案,感兴趣的同学可以尝试一下,在评论区call我哟~

image.png
image.png
3.2.1 节点替换

思路上其实非常简单,只要将选中的部分替换成修改过样式的新元素即可

但是尝试之下才发现不是这么回事,以下这是我踩过的坑

  • 选中的不是一个标签元素而只是元素内的文本应该怎么处理?
image.png
image.png
  • 从前往后选和从后往前选的区别在哪?
image.png
image.png
image.png
image.png
  • 怎么替换元素来保证可拓展性?
代码语言:javascript
复制
const selectionReplace = (
  baseNode: HTMLElement,
  baseOffset: number,
  text: string,
  render: ((arg0: string) => JSX.Element) | JSX.Element | string
): [string, string] => {
  const id = `selection-replace-${Date.now()}`;

  const baseText = baseNode.textContent.slice(
    baseOffset,
    baseOffset + text.length
  );
  const baseHTML = baseNode.innerHTML ?? convertHTML(baseText);
  const html = baseNode.parentElement.outerHTML;
  const headLength = html.split(baseHTML).at(0).length;

  let newOuterHTML = null;
  const content = (
    <span id={id}>
      {render instanceof Function ? render(baseText) : render}
    </span>
  );
  const contentHTML = renderToString(content);
  
  if (headLength === html.length) {
    newOuterHTML = html.replace(baseHTML, contentHTML);
  } else {
    const headHTML = html.slice(0, headLength);
    const tailHTML = html.split(headHTML)[1].replace(baseHTML, contentHTML);
    newOuterHTML = `${headHTML}${tailHTML}`;
  }

  return [id, newOuterHTML];
};
#1 基本的节点替换

什么是基本节点,我这里的定义是将被替换文本的归属节点,而不是Selection对象上的那个baseNode

image-20220719230538384
image-20220719230538384

我们可以从baseNode得到「nodeType」「parentElement」「textContent」三个主要信息,这些信息的作用是在选区替换时帮助定位被替换的HTML文本。

image-20220719231315785
image-20220719231315785

selectionReplace之所以实现得如此复杂,主要源自两个问题。

image-20220719232113941
image-20220719232113941

一个是选区内重复文字的问题,这促使我们只能通过索引的方式来定位被替换的元素。

另一个则是由于HTML和文本的区别,一个节点的outerHTMLinnerHTML在处理类似<这样的符号是需要进行转译的。

image-20220719232436217
image-20220719232436217
#2 可遍历操作链路

如果要做到撤销和反撤销,就意味着我们要能做到以下三点:

  • 我们可以通过某种方式再次定位到用户选择的选区
  • 我们可以定位到自己添加的节点
  • 保存插入的HTML内容以及被插入的TEXT

所以我们的ReplaceEffect类型如下

image-20220719235648431
image-20220719235648431

那么我的处理方案是,基于xpath来进行选区。因为我们会发现正常的选择器并不能选择到某一个/段文本(否则也不会需要做文本替换)

image-20220719234346375
image-20220719234346375

这样处理出来的XPath类似于

代码语言:javascript
复制
'id("gatsby-focus-wrapper")/DIV[1]/DIV[1]/DIV[2]//DIV[2]/DIV[1]/DIV[2]/DIV[2]'

再次使用的时候可以通过document.evalute这个API进行选择

image-20220719234731410
image-20220719234731410

而对于定位自己添加的节点,我们在节点替换时就会有一个带有id的span容器,所以可以很轻松的获得替换上去的节点。

image-20220719235228884
image-20220719235228884
image-20220719235241655
image-20220719235241655

在这之后,我们要处理的就是如何进行替换,这里的方法统一都是通过替换outerHTML,outerHTML代表的是对应节点本身,所以我们替换的时候是替换父节点(因为我们之前保存的xpath是选区的归属TEXT节点),反替换时更轻松,直接替换对应id的outerHTML复原到原本的文本

image-20220720000120259
image-20220720000120259
3.2.2 链路重现

我们定义了mountEffectListunmountEffectList用来区分已经在页面展现的替换作用从页面展现被卸载的替换作用

这种情况下,我们可以轻松定义一个全局撤销与反撤销

27F141F8A438C8E0D0EC9A0C760678FB
27F141F8A438C8E0D0EC9A0C760678FB

诶,这不就完了~

image-20220720001054157
image-20220720001054157

4 总结

那么看完本文,我们总结一下收获:

  • 在实践中第一次感知到HTML文本和TEXT文本的区别
  • 第一次知道Selection这个类
  • 第一次真正使用XPath
  • 对于链路重现的经验

值得一提的是,由于实现的非常易用,我正在考虑在比较与实现其他不同其他方案后另外拉一个仓库做一个页面样式调整工具的开源

image-20220720003149484
image-20220720003149484
本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
原始发表:2022-07-20,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 为了秋招,我开发了这个页面元素高亮插件
    • 1 前言
      • 2 介绍
        • 2.1 插件背景
        • 2.2 插件预期实现效果
        • 2.3 插件实现重点难点
      • 3 实现思路
        • 3.1 右键菜单
        • 3.2 替换页面元素
      • 4 总结
      相关产品与服务
      容器服务
      腾讯云容器服务(Tencent Kubernetes Engine, TKE)基于原生 kubernetes 提供以容器为核心的、高度可扩展的高性能容器管理服务,覆盖 Serverless、边缘计算、分布式云等多种业务部署场景,业内首创单个集群兼容多种计算节点的容器资源管理模式。同时产品作为云原生 Finops 领先布道者,主导开源项目Crane,全面助力客户实现资源优化、成本控制。
      领券
      问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档