我正在参加「创意开发 投稿大赛」详情请看:掘金创意开发大赛来了!
大家好,我是心锁,一枚23届准毕业生。
随着七八月的到来,大小厂们都开始了秋招提前批,在这个背景下,写出一份优秀的简历无疑是面试邀请的敲门砖。
所以撒,基于这个想法,我在visiky
大佬开源基于React+Ts的https://github.com/visiky/resume
简历生成器的基础上开发了一款简历高亮(页面元素高亮)插件。
先简单看看实现效果我们再继续介绍
作为一名面试官,我希望我可以第一眼扫过简历就得到被面试者的亮点信息。
经过换位思考,我认为应该给自己的简历做高亮处理,就像现在这样。
预期中,插件不可能只做高亮/标注这一个工作,我希望实现以下内容:
那么为了实现以上内容,我们无疑可以提炼出相关的重点难点,同时这也将是你我可以从本文学习到的东西。
实现方案上,我选择的是让用户选中文本后右键弹出选项菜单,从而允许用户进行标注等一系列的工作。
那么在此基础上,我们面临的第一个问题就是,如何友好的实现右键打开菜单
右键菜单,理解中应该是一个弹出层。
那么语义上,实际中,右键菜单都应该以一个独立节点的方式插入到页面中。
在React中,想将一个组件插入页面中,我们只能借助原生方法,否则我们只能在ReactDOM.render
选中的节点下操作。
所以第一步,提炼一个useAppendRootNode
的自定义hook,方便进行节点插入。
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;
看一下这份代码,主要是show
和destory
这两个方法。
我们要在页面上插入一个Root节点,第一步自然是判断这个节点是否已经存在,然后才通过createElement
或者document.createElement
的方法来获得一个HTMLElement
元素。
同时需要注意,为了适配更多业务场景,这个hook也应当支持选择被插入的父节点。
插入节点这种操作是一种副作用,我们同时需要定义一个销毁节点的方法,一方面可以在useEffect
中清除副作用,一方面也方便提供给hook的使用者手动调用。
最后一步是对上边两个方法对调用,同时注意我们需要通过ReactDOM.render
的API将React组件渲染到刚才的创建的节点上。
ReactDOM.ceatePortals
将节点渲染到其他DOM节点上,本质上仍和主干应用处于同一颗ReactTree理论上讲,渲染右键菜单并不麻烦。
麻烦的是我们如何确定菜单呈现的位置,如何模拟正常的操作菜单的交互
这里看着可能会模糊看一下这里,为什么我需要将ref.current
的宽高赋值给memoAttr
?
原因在于,我们的菜单组件,在display:none
的时候是没有宽高的,我们需要在一开始便拿到组件的宽高,以便于在隐藏的时候仍可以做计算。
哈?那为什么不用visibility
来控制显隐?这样既可以隐藏又可以得到宽高。
原因有两个:
visibility
属性虽然会被继承,但是如果子元素设置visibility: visible
会使得子元素显示,这无疑会给我们使用第三方组件时带来一定的心智负担。而display:none
不会有这个困扰visibility
语义上只是看不见了,但是正常的菜单应该是消失,我比较认同符合语义的实现我们可以通过监听contextmenu
事件来知悉用户右键试图打开操作菜单的行为。
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 x =
(clientWidth + clientX + scrollLeft > windowWidth
? clientX - clientWidth
: clientX) + scrollLeft;
const y =
(clientHeight + clientY + scrollTop > windowHeight
? clientY - clientHeight
: clientY) + scrollTop;
思路是计算菜单实际宽度+页面点击X坐标+已滑动x轴位置是否大于容器宽度,是的话就反向显示操作菜单,否则正常显示。
同理,计算y坐标也是同样的道理。
MAC的右键菜单有且只有一种关闭方式,那就是点击菜单可选区关闭和点击页面其他地方关闭。此时禁用窗口拖动、滑动。而我们实现中为了方便,对于禁用窗口拖动、滑动采取的方案是在这种情况下直接关闭菜单。
注意对于size
和scroll
这两种事件还是加个节流
这里的方案是通过window.getSelection()
来获得选区,如图是一个Selection对象,具体方法可以搜索一下MDN
然后就是目前替换方案实际上还有瑕疵,在处理多节点时存在一定问题,所以我这里其实还有一套待实现的方案,感兴趣的同学可以尝试一下,在评论区call我哟~
思路上其实非常简单,只要将选中的部分替换成修改过样式的新元素即可。
但是尝试之下才发现不是这么回事,以下这是我踩过的坑
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];
};
什么是基本节点,我这里的定义是将被替换文本的归属节点,而不是Selection
对象上的那个baseNode
我们可以从baseNode
得到「nodeType」「parentElement」「textContent」三个主要信息,这些信息的作用是在选区替换时帮助定位被替换的HTML文本。
而selectionReplace
之所以实现得如此复杂,主要源自两个问题。
一个是选区内重复文字的问题,这促使我们只能通过索引的方式来定位被替换的元素。
另一个则是由于HTML
和文本的区别,一个节点的outerHTML
或innerHTML
在处理类似<
这样的符号是需要进行转译的。
如果要做到撤销和反撤销,就意味着我们要能做到以下三点:
HTML
内容以及被插入的TEXT
所以我们的ReplaceEffect
类型如下
那么我的处理方案是,基于xpath
来进行选区。因为我们会发现正常的选择器并不能选择到某一个/段文本(否则也不会需要做文本替换)
这样处理出来的XPath类似于
'id("gatsby-focus-wrapper")/DIV[1]/DIV[1]/DIV[2]//DIV[2]/DIV[1]/DIV[2]/DIV[2]'
再次使用的时候可以通过document.evalute
这个API进行选择
而对于定位自己添加的节点,我们在节点替换时就会有一个带有id的span容器,所以可以很轻松的获得替换上去的节点。
在这之后,我们要处理的就是如何进行替换,这里的方法统一都是通过替换outerHTML
,outerHTML代表的是对应节点本身,所以我们替换的时候是替换父节点(因为我们之前保存的xpath是选区的归属TEXT节点),反替换时更轻松,直接替换对应id的outerHTML复原到原本的文本
我们定义了mountEffectList
和unmountEffectList
用来区分已经在页面展现的替换作用与从页面展现被卸载的替换作用
这种情况下,我们可以轻松定义一个全局撤销与反撤销
诶,这不就完了~
那么看完本文,我们总结一下收获:
Selection
这个类值得一提的是,由于实现的非常易用,我正在考虑在比较与实现其他不同其他方案后另外拉一个仓库做一个页面样式调整工具的开源
扫码关注腾讯云开发者
领取腾讯云代金券
Copyright © 2013 - 2025 Tencent Cloud. All Rights Reserved. 腾讯云 版权所有
深圳市腾讯计算机系统有限公司 ICP备案/许可证号:粤B2-20090059 深公网安备号 44030502008569
腾讯云计算(北京)有限责任公司 京ICP证150476号 | 京ICP备11018762号 | 京公网安备号11010802020287
Copyright © 2013 - 2025 Tencent Cloud.
All Rights Reserved. 腾讯云 版权所有