├── .gitignore
├── README.md
├── assets
├── react.svg
├── runtime_screenshot_1.png
└── runtime_screenshot_2.png
├── entrypoints
├── background.ts
├── components
│ ├── element_info
│ │ ├── card_head.tsx
│ │ ├── card_input.tsx
│ │ ├── element_card.tsx
│ │ └── element_info.tsx
│ └── spotlight
│ │ └── spotlight.tsx
├── content.tsx
├── hooks
│ └── use_dynamic_styles.ts
├── rule
│ └── rule.ts
├── store
│ ├── element_card.ts
│ ├── mouse.ts
│ └── rule.ts
└── utlis
│ └── tools.tsx
├── eslint.config.js
├── package.json
├── pnpm-lock.yaml
├── public
├── css
│ └── element_info
│ │ └── index.css
├── icon
│ ├── 128.png
│ ├── 16.png
│ ├── 32.png
│ ├── 48.png
│ └── 96.png
└── wxt.svg
├── tsconfig.json
├── uno.config.ts
└── wxt.config.ts
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | .output
12 | stats.html
13 | stats-*.json
14 | .wxt
15 | web-ext.config.ts
16 |
17 | # Editor directories and files
18 | .vscode/*
19 | !.vscode/extensions.json
20 | .idea
21 | .DS_Store
22 | *.suo
23 | *.ntvs*
24 | *.njsproj
25 | *.sln
26 | *.sw?
27 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ##### 基于wxt开发的浏览器插件方便您快速调试tailwind css
2 |
3 | ###### 启动命令
4 |
5 | ```
6 | pnpm install
7 | pnpm run dev
8 | ```
9 |
10 | ###### 已完成功能
11 | - 悬停显示元素信息高亮显示
12 | - 元素class可选可删除
13 | - 添加tialwind css 类和补全信息
14 | - 复制元素样式 dom整体
15 |
16 |
17 | ###### 待完成
18 | - copy 原生css转成 tailwind css
19 |
20 |
21 | ###### 运行截图
22 | 
23 | 
24 |
25 |
26 |
27 | ###### 项目目录
28 |
29 | ```
30 | .
31 | ├── .gitignore # Git忽略文件,列出在版本控制中要忽略的文件和目录
32 | ├── assets # 静态资源文件夹
33 | │ └── ...静态资源
34 | ├── entrypoints # 入口点文件夹
35 | │ ├── background.ts # Chrome扩展的后台脚本
36 | │ ├── components # 组件文件夹
37 | │ │ ├── element_info # 元素信息相关组件
38 | │ │ │ ├── card_head.tsx # 卡片头部组件
39 | │ │ │ ├── card_input.tsx # 卡片输入组件
40 | │ │ │ ├── element_card.tsx # 元素卡片组件
41 | │ │ │ └── element_info.tsx # 元素信息组件
42 | │ │ └── spotlight # 聚焦组件
43 | │ │ └── spotlight.tsx # 聚焦组件实现
44 | │ ├── content.tsx # 内容脚本
45 | │ ├── hooks # 自定义hooks
46 | │ │ └── use_dynamic_styles.ts # 动态样式的自定义hook
47 | │ ├── rule # 规则相关文件
48 | │ │ └── rule.ts # 规则定义
49 | │ ├── store # 状态管理
50 | │ │ ├── element_card.ts # 元素卡片状态管理
51 | │ │ ├── mouse.ts # 鼠标状态管理
52 | │ │ └── rule.ts # 规则状态管理
53 | │ └── utils # 工具函数
54 | │ └── tools.tsx # 工具函数实现
55 | ├── eslint.config.js # ESLint配置文件
56 | ├── index.js # 项目的入口文件
57 | ├── package.json # 项目的依赖配置和元数据
58 | ├── pnpm-lock.yaml # pnpm锁定文件,记录依赖的确切版本
59 | ├── public # 公共资源文件夹
60 | │ ├── css # CSS文件夹
61 | │ │ └── element_info # 元素信息相关CSS
62 | │ │ └── index.css # 元素信息组件样式
63 | │ ├── icon # 图标文件夹
64 | │ │ ├── 128.png # 128x128图标
65 | │ │ ├── 16.png # 16x16图标
66 | │ │ ├── 32.png # 32x32图标
67 | │ │ ├── 48.png # 48x48图标
68 | │ │ └── 96.png # 96x96图标
69 | │ └── wxt.svg # WXT图标文件
70 | ├── README.md # 项目说明文档
71 | ├── tsconfig.json # TypeScript配置文件
72 | ├── uno.config.ts # UnoCSS配置文件
73 | └── wxt.config.ts # WXT框架配置文件
74 | ```
--------------------------------------------------------------------------------
/assets/react.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/assets/runtime_screenshot_1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/suanju/tailexamine/89d7457f480bab9c993939797649e260525c1e63/assets/runtime_screenshot_1.png
--------------------------------------------------------------------------------
/assets/runtime_screenshot_2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/suanju/tailexamine/89d7457f480bab9c993939797649e260525c1e63/assets/runtime_screenshot_2.png
--------------------------------------------------------------------------------
/entrypoints/background.ts:
--------------------------------------------------------------------------------
1 | export default defineBackground(() => {
2 | chrome.action.onClicked.addListener((tab) => {
3 | chrome.tabs.sendMessage(tab.id!, { toggle: true });
4 | });
5 | });
6 |
--------------------------------------------------------------------------------
/entrypoints/components/element_info/card_head.tsx:
--------------------------------------------------------------------------------
1 | import { useElementCardStore } from "@/entrypoints/store/element_card";
2 | import { useMouseStore } from "@/entrypoints/store/mouse";
3 | import { CloseOutlined, CopyOutlined, FullscreenOutlined, NodeExpandOutlined, NumberOutlined, SnippetsOutlined } from "@ant-design/icons";
4 | import { Tooltip } from "antd";
5 | import { useRef, useEffect, useCallback, useState } from "react";
6 | import { throttle } from 'radash';
7 |
8 | export default () => {
9 | const { element } = useMouseStore();
10 | const { elementCardPosition, setElementCardPosition, isMove, setIsMove } = useElementCardStore();
11 | const dragStartRef = useRef<{ x: number, y: number }>({ x: 0, y: 0 });
12 | const [isDragging, setIsDragging] = useState(false);
13 | const [isTipsOpen, setIsTipsOpen] = useState(false);
14 | const [tooltipText, setTooltipText] = useState({ copyClasses: 'Copy classes', copyElement: 'Copy element', moveWindow: 'Move window', closeWindow: 'Close window' });
15 |
16 | const updatePosition = useCallback((e: MouseEvent) => {
17 | if (isMove) {
18 | const { clientX, clientY } = e;
19 | //减少重绘次数
20 | requestAnimationFrame(() => {
21 | setElementCardPosition(clientY - dragStartRef.current.y, clientX - dragStartRef.current.x);
22 | });
23 | }
24 | }, [isMove, setElementCardPosition]);
25 |
26 | const cleanupListeners = useCallback(() => {
27 | setIsTipsOpen(false);
28 | document.body.style.userSelect = '';
29 | document.removeEventListener('mousemove', updatePosition);
30 | document.removeEventListener('mouseup', handleMouseUp);
31 | }, [updatePosition]);
32 |
33 | const addListeners = useCallback(() => {
34 | setIsTipsOpen(true);
35 | document.body.style.userSelect = 'none';
36 | document.addEventListener('mousemove', updatePosition);
37 | document.addEventListener('mouseup', handleMouseUp);
38 | }, [updatePosition]);
39 |
40 | const handleMouseUp = useCallback(() => {
41 | setIsMove(false);
42 | cleanupListeners();
43 | }, [cleanupListeners, setIsMove]);
44 |
45 | const toggleDragMode = useCallback((e: React.MouseEvent) => {
46 | e.stopPropagation();
47 | if (!isDragging) {
48 | dragStartRef.current = {
49 | x: e.clientX - elementCardPosition.left,
50 | y: e.clientY - elementCardPosition.top,
51 | };
52 | setIsMove(true);
53 | addListeners();
54 | } else {
55 | setIsMove(false);
56 | cleanupListeners();
57 | }
58 | setIsDragging((prev) => !prev);
59 | }, [isDragging, elementCardPosition.left, elementCardPosition.top, setIsMove, addListeners, cleanupListeners]);
60 |
61 | // 窗口拖动
62 | useEffect(() => {
63 | if (isDragging) {
64 | addListeners();
65 | } else {
66 | setIsTipsOpen(true);
67 | cleanupListeners();
68 | }
69 | return cleanupListeners;
70 | }, [isDragging, addListeners, cleanupListeners]);
71 |
72 | // 关闭窗口
73 | const close = () => {
74 | // setElement(null);
75 | // setIsListeningMouse(true);
76 | // @ts-ignore
77 | window.removeTailexamine()
78 | };
79 |
80 | // 复制类名,使用节流
81 | const copyClasses = throttle({ interval: 2000 },
82 | () => {
83 | if (element) {
84 | const classNames = element.className;
85 | navigator.clipboard.writeText(classNames).then(() => {
86 | setTooltipText((prev) => ({ ...prev, copyClasses: '☑️ Classes copied!' }));
87 | setTimeout(() => {
88 | setTooltipText((prev) => ({ ...prev, copyClasses: 'Copy classes' }));
89 | }, 1000);
90 | });
91 | }
92 | }
93 | );
94 |
95 | // 复制完整元素,使用节流
96 | const copyElement = throttle({ interval: 2000 },
97 | () => {
98 | if (element) {
99 | const elementHtml = element.outerHTML;
100 | navigator.clipboard.writeText(elementHtml).then(() => {
101 | setTooltipText((prev) => ({ ...prev, copyElement: '☑️ Element copied!' }));
102 | setTimeout(() => {
103 | setTooltipText((prev) => ({ ...prev, copyElement: 'Copy element' }));
104 | }, 2000);
105 | });
106 | }
107 | }
108 | );
109 |
110 | return (
111 |
112 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
130 |
131 |
132 |
133 |
134 |
135 |
136 | );
137 | };
138 |
--------------------------------------------------------------------------------
/entrypoints/components/element_info/card_input.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from "react";
2 | import { ElementInfoId } from "@/entrypoints/components/element_info/element_info";
3 | import { useMouseStore } from "@/entrypoints/store/mouse";
4 | import useDynamicStyles from '@/entrypoints/hooks/use_dynamic_styles';
5 | import { Select, Space } from "antd";
6 | import { getColorFromRule, areCSSRulesSame } from "@/entrypoints/utlis/tools";
7 | import { options, rules } from "../../rule/rule";
8 |
9 | const optionRender = (option: any) => {
10 | const { isColor, color } = getColorFromRule(option.data.descr);
11 | return (
12 |
13 |
14 | {isColor ?
15 |
: }
16 | {option.data.label}
17 |
18 |
19 | {option.data.descr}
20 |
21 |
22 | );
23 | };
24 |
25 | export default ({ delTag }: { delTag: (Tag: string) => void }) => {
26 | const { element, setElement } = useMouseStore();
27 | const { addStyle } = useDynamicStyles();
28 | const [selectedValue, setSelectedValue] = useState(null);
29 | const [searchValue, setSearchValue] = useState("");
30 | const [availableOptions, setAvailableOptions] = useState(options);
31 |
32 | useEffect(() => {
33 | if (searchValue) {
34 | setAvailableOptions(options.filter((option) =>
35 | option.label.includes(searchValue) || option.descr.includes(searchValue)
36 | ));
37 | } else {
38 | setAvailableOptions([]);
39 | }
40 | }, [searchValue]);
41 |
42 | // 处理搜索框输入变化
43 | const handleSearch = (value: string) => {
44 | setSearchValue(value);
45 | };
46 |
47 | // 选中事件
48 | const handleSelect = (value: string) => {
49 | if (element) {
50 | //作用相同规则进行去除
51 | element.classList.forEach((className: string) => {
52 | console.log("className", className, rules[className]);
53 | if (rules[className]) {
54 | //删除规则
55 | console.log("删除规则", className, areCSSRulesSame(rules[className], rules[value]));
56 | if (areCSSRulesSame(rules[className], rules[value])) {
57 | element.classList.remove(className);
58 | delTag(className)
59 | }
60 | }
61 | });
62 | element.classList.add(value);
63 | const styleRule = rules[value];
64 | if (styleRule) addStyle(value, styleRule);
65 | setSelectedValue(null);
66 | setSearchValue("");
67 | setElement(element);
68 | }
69 | };
70 |
71 | return (
72 |
73 |
74 |
86 |
87 | );
88 | };
89 |
--------------------------------------------------------------------------------
/entrypoints/components/element_info/element_card.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, useRef } from 'react';
2 | import "@/public/css/element_info/index.css";
3 | import CardHead from "./card_head";
4 | import CardInput from "./card_input";
5 | import { Card, Checkbox, CheckboxOptionType, GetProp, Tag } from 'antd';
6 | import { getElementStructure } from "@/entrypoints/utlis/tools";
7 | import { useMouseStore } from "@/entrypoints/store/mouse";
8 | import { CloseCircleOutlined } from "@ant-design/icons";
9 |
10 | export default () => {
11 | const { element, setElement } = useMouseStore();
12 | const [plainOptions, setPlainOptions] = useState([]);
13 | const [checkedList, setCheckedList] = useState([]);
14 | const [hoveredTag, setHoveredTag] = useState(null); // 用于跟踪悬停的标签
15 | const optionsRef = useRef(plainOptions);
16 | const checkedRef = useRef(checkedList);
17 | const elementRef = useRef(element);
18 |
19 | useEffect(() => {
20 | if (element) {
21 | // dom 变化 CheckedList 重置
22 | setCheckedList([]);
23 | const updateOptions = () => {
24 | const toggleOffClass = element.dataset.toggleOffClass;
25 | const toggleOffClassArr = toggleOffClass?.split(' ');
26 | if (elementRef.current !== element) {
27 | setPlainOptions([]);
28 | if (element) {
29 | const newOptions = Array.from([...element.classList, ...[toggleOffClass ? toggleOffClassArr : []].flat()]).map((className) => ({
30 | label: className,
31 | value: className,
32 | }));
33 | setPlainOptions(newOptions);
34 | }
35 | elementRef.current = element;
36 | } else if (elementRef.current === element) {
37 | // 同一个 dom class 有更新
38 | elementRef.current.classList.forEach(className => {
39 | if (!optionsRef.current.some(option => option.value === className)) {
40 | setPlainOptions(prevOptions => [...prevOptions, { label: className, value: className }]);
41 | }
42 | });
43 | }
44 | };
45 |
46 | updateOptions();
47 | const observer = new MutationObserver(() => {
48 | updateOptions();
49 | });
50 |
51 | observer.observe(element, { attributes: true, attributeFilter: ['class'] });
52 | return () => observer.disconnect();
53 | }
54 | }, [element]);
55 |
56 | useEffect(() => {
57 | optionsRef.current = plainOptions;
58 | checkedRef.current = checkedList;
59 | const toggleOffClass = element?.dataset.toggleOffClass;
60 | const opList = optionsRef.current.map(op => op.value);
61 | // 获取新增的 class
62 | const addChecked = opList.filter(item => !checkedRef.current.includes(item));
63 | setCheckedList(prevChecked => {
64 | return [...prevChecked, ...addChecked.filter(item => !toggleOffClass?.split(' ').includes(item))];
65 | });
66 | }, [element, plainOptions]);
67 |
68 | const handleCheckboxGroupChange: GetProp, 'onChange'> = (list) => {
69 | // 进行更新 class
70 | if (element) {
71 | // 将取消的 class 插入 toggle-off-class
72 | const classList = Array.from(element?.classList);
73 | const toggleOffClass = element.dataset.toggleOffClass;
74 | const diffClassListToList = classList.filter(className => !list.includes(className));
75 | const diffListToClassList = list.filter(className => !classList.includes(className));
76 | // 添加新增 toggleOffClass class
77 | if (diffClassListToList.length) {
78 | const className = diffClassListToList.toString();
79 | console.log("添加", className);
80 | element.dataset.toggleOffClass = toggleOffClass ? `${toggleOffClass} ${className}` : className;
81 | }
82 | // 删除失效 toggleOffClass class
83 | if (diffListToClassList.length) {
84 | diffListToClassList.forEach(className => {
85 | element.dataset.toggleOffClass = toggleOffClass?.split(' ').filter(item => className !== item).join(" ");
86 | });
87 | }
88 | // 重置 class
89 | element.classList.value = '';
90 | list.forEach(className => {
91 | element.classList.add(className as string);
92 | });
93 | // 重新计算高亮位置
94 | setElement(element);
95 | }
96 | setCheckedList(list);
97 | };
98 |
99 | const handleTagClose = (removedOption: string) => {
100 | console.log("close", removedOption);
101 | // 删除实体 element 的 class toggleOffClass
102 | if (element) {
103 | element.classList.remove(removedOption);
104 | const toggleOffClass = element.dataset.toggleOffClass;
105 | const toggleOffClassArr = toggleOffClass?.split(' ');
106 | if (toggleOffClassArr?.includes(removedOption)) {
107 | element.dataset.toggleOffClass = toggleOffClassArr.filter(item => item !== removedOption).join(" ");
108 | }
109 | }
110 | // 修改选项列表和已选中列表
111 | setPlainOptions(plainOptions.filter(option => option.value !== removedOption));
112 | setCheckedList(prev => prev.filter(option => option !== removedOption));
113 | };
114 |
115 | const handleMouseEnter = (value: string) => {
116 | setHoveredTag(value);
117 | };
118 |
119 | const handleMouseLeave = () => {
120 | setHoveredTag(null);
121 | };
122 |
123 | return (
124 |
128 |
129 |
130 |
131 | {element ? (
132 |
134 |
138 |
139 | {getElementStructure(element)}
140 |
141 | {plainOptions.map(option => (
142 | handleMouseEnter(option.value)}
146 | onMouseLeave={handleMouseLeave}
147 | >
148 |
149 |
150 | {option.label}
151 |
152 | {hoveredTag === option.value && (
153 | handleTagClose(option.value)}
156 | />
157 | )}
158 |
159 |
160 | ))}
161 |
162 |
163 | ) : 'No element selected'}
164 |
165 |
166 |
167 |
168 | );
169 | };
170 |
--------------------------------------------------------------------------------
/entrypoints/components/element_info/element_info.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useCallback } from 'react';
2 | import { useMouseStore } from "@/entrypoints/store/mouse";
3 | import ElementInfoCard from "./element_card";
4 | import { useElementCardStore } from "@/entrypoints/store/element_card";
5 |
6 | export const ElementInfoId = "element-info";
7 | const cardWidth = 336;
8 | const cardHeight = 368; // 假设卡片的高度为 200px
9 |
10 | const useCardFollow = (setElementCardPosition: (top: number, left: number) => void) => {
11 | const { isListeningMouse } = useMouseStore();
12 |
13 | const handleMouseMove = useCallback((event: MouseEvent) => {
14 | requestAnimationFrame(() => {
15 | const windowWidth = window.innerWidth;
16 | const windowHeight = window.innerHeight;
17 | const offset = 40;
18 |
19 | // 判断左右半屏
20 | const isLeftSide = event.pageX < windowWidth / 2;
21 | // 判断上下半屏
22 | const isTopSide = event.pageY < windowHeight / 2;
23 |
24 | // 根据鼠标位置决定卡片的 left 值
25 | const left = isLeftSide
26 | ? event.pageX + offset
27 | : event.pageX - offset - cardWidth;
28 | // 根据鼠标位置决定卡片的 top 值
29 | const top = isTopSide
30 | ? event.pageY + offset
31 | : event.pageY - offset - cardHeight;
32 |
33 | setElementCardPosition(top, left);
34 | });
35 | }, [setElementCardPosition]);
36 |
37 | useEffect(() => {
38 | if (isListeningMouse) {
39 | window.addEventListener('mousemove', handleMouseMove);
40 | return () => {
41 | console.log('卸载监听 remove mousemove')
42 | window.removeEventListener('mousemove', handleMouseMove);
43 | };
44 | }
45 | }, [isListeningMouse, handleMouseMove]);
46 | };
47 |
48 | export default () => {
49 | const { elementCardPosition, setElementCardPosition } = useElementCardStore();
50 | useCardFollow(setElementCardPosition);
51 |
52 | const style = {
53 | left: `${elementCardPosition.left}px`,
54 | top: `${elementCardPosition.top}px`,
55 | };
56 |
57 | return (
58 |
63 |
64 |
65 | );
66 | };
67 |
--------------------------------------------------------------------------------
/entrypoints/components/spotlight/spotlight.tsx:
--------------------------------------------------------------------------------
1 | import { useMouseStore } from "@/entrypoints/store/mouse";
2 | import { useEffect, useState } from "react";
3 | import { getScrollbarWidth } from "@/entrypoints/utlis/tools";
4 |
5 | export default () => {
6 | const [scrollTop, setScrollTop] = useState(window.scrollY);
7 | const [highlightRect, setHighlightRect] = useState<{ x: number; y: number; width: number; height: number } | null>(null);
8 | const [infoText, setInfoText] = useState("");
9 | const [infoTextPosition, setInfoTextPosition] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
10 | const [lines, setLines] = useState<{ x1: number; y1: number; x2: number; y2: number }[]>([]);
11 | const { element, lastEvent, isListeningMouse } = useMouseStore();
12 | const [viewBox, setViewBox] = useState(`0 0 ${window.innerWidth} ${window.innerHeight}`);
13 |
14 | const updateLines = (rect: DOMRect) => {
15 | const right = rect.right;
16 | const left = rect.left;
17 | const top = rect.top;
18 | const bottom = rect.bottom;
19 | // 设置辅助线
20 | setLines([
21 | { x1: left, y1: 0, x2: left, y2: Math.max(document.documentElement.scrollHeight, document.body.scrollHeight) },
22 | { x1: right, y1: 0, x2: right, y2: Math.max(document.documentElement.scrollHeight, document.body.scrollHeight) },
23 | { x1: 0, y1: top, x2: Math.max(document.documentElement.clientWidth, document.body.clientWidth), y2: top },
24 | { x1: 0, y1: bottom, x2: Math.max(document.documentElement.clientWidth, document.body.clientWidth), y2: bottom },
25 | ]);
26 | };
27 |
28 | const updateHighlightAndLines = () => {
29 | const target = lastEvent?.target as HTMLElement;
30 | if (target && target !== document.body && target !== document.documentElement) {
31 | const rect = target.getBoundingClientRect();
32 | setScrollTop(window.scrollY);
33 |
34 | setHighlightRect({
35 | x: rect.left,
36 | y: rect.top,
37 | width: rect.width,
38 | height: rect.height,
39 | });
40 |
41 | setInfoText(`${Math.round(rect.width)} x ${Math.round(rect.height)}`);
42 | setInfoTextPosition({
43 | x: rect.right - 4,
44 | y: rect.top - 10,
45 | });
46 |
47 | updateLines(rect);
48 | } else {
49 | setHighlightRect(null);
50 | setInfoText("");
51 | setLines([]);
52 | }
53 | };
54 |
55 | useEffect(() => {
56 | const handleResize = () => {
57 | console.log(window.innerWidth - getScrollbarWidth(), window.innerHeight);
58 | setViewBox(`0 0 ${window.innerWidth - getScrollbarWidth()} ${window.innerHeight}`);
59 | updateHighlightAndLines(); // 手动调用更新逻辑
60 | };
61 |
62 | handleResize();
63 | window.addEventListener("resize", handleResize);
64 | return () => window.removeEventListener("resize", handleResize);
65 | }, []);
66 |
67 | useEffect(() => {
68 | updateHighlightAndLines(); // 使用提取的函数
69 | }, [lastEvent, element?.className]);
70 |
71 | return (
72 |
73 |
117 |
118 | );
119 | };
120 |
--------------------------------------------------------------------------------
/entrypoints/content.tsx:
--------------------------------------------------------------------------------
1 | import 'virtual:uno.css';
2 | import "antd/dist/reset.css";
3 | import { createRoot } from "react-dom/client";
4 | import ElementHighlighter from "./components/spotlight/spotlight";
5 | import ElementInfo, { ElementInfoId } from "./components/element_info/element_info";
6 | import { useMouseStore } from '@/entrypoints/store/mouse';
7 | import { ConfigProvider, App } from 'antd';
8 | import { useEffect, useCallback } from 'react';
9 | import { throttle } from 'radash';
10 |
11 | export default defineContentScript({
12 | matches: ['*://*/*'],
13 | main(ctx) {
14 | console.log('init content', ctx);
15 | let root: HTMLElement | null = null;
16 | let tailexamineRoot: any = null;
17 |
18 | //点击插件事件
19 | chrome.runtime.onMessage.addListener((request) => {
20 | if (request.toggle) {
21 | toggleTailexamine();
22 | }
23 | });
24 |
25 | const toggleTailexamine = () => {
26 | if (root) {
27 | removeTailexamine();
28 | } else {
29 | createTailexamine();
30 | }
31 | };
32 |
33 | const createTailexamine = () => {
34 | if (!root) {
35 | root = document.createElement("div");
36 | root.id = "tailexamine";
37 | document.body.appendChild(root);
38 | }
39 | //避免重复创建
40 | if (!tailexamineRoot) {
41 | tailexamineRoot = createRoot(root);
42 | }
43 | tailexamineRoot.render();
44 | console.log('tailexamine created');
45 | };
46 |
47 | const removeTailexamine = () => {
48 | if (root && tailexamineRoot) {
49 | tailexamineRoot.unmount();
50 | root.remove();
51 | root = null;
52 | tailexamineRoot = null;
53 | console.log('tailexamine removed');
54 | }
55 | };
56 |
57 | // 挂载到 window
58 | (window as any).removeTailexamine = removeTailexamine;
59 | console.log((window as any).removeTailexamine);
60 | },
61 | });
62 |
63 |
64 |
65 | // 监听鼠标的自定义 Hook
66 | const useMouseListener = () => {
67 | const { element, setElement, setLastEvent, setPosition, isListeningMouse, setIsListeningMouse } = useMouseStore();
68 |
69 | const handleMouseMove = useCallback(
70 | throttle({
71 | interval: 100, // 设置节流间隔时间为 100ms,视实际需要调整
72 | }, (event: MouseEvent) => {
73 | // 更新鼠标信息
74 | console.log(element !== event.target);
75 | if (element !== event.target) {
76 | setElement(event.target as HTMLElement);
77 | setPosition(event.clientX, event.clientY);
78 | setLastEvent(event);
79 | }
80 | }),
81 | [element, setElement, setLastEvent, setPosition]
82 | );
83 |
84 | const handleClick = useCallback((e: MouseEvent) => {
85 | if (!document.getElementById(ElementInfoId)?.contains(e.target as Node)) {
86 | e.stopPropagation(); // 冒泡
87 | e.stopImmediatePropagation(); // 其他监听器
88 | e.preventDefault(); // 默认行为
89 | setLastEvent(e);
90 | setPosition(e.clientX, e.clientY);
91 | setIsListeningMouse(!isListeningMouse);
92 | }
93 | }, [isListeningMouse, setLastEvent, setPosition, setIsListeningMouse]);
94 |
95 | useEffect(() => {
96 | if (isListeningMouse) {
97 | window.addEventListener('mousemove', handleMouseMove);
98 | return () => {
99 | window.removeEventListener('mousemove', handleMouseMove);
100 | };
101 | }
102 | }, [isListeningMouse, handleMouseMove]);
103 |
104 | useEffect(() => {
105 | window.addEventListener('click', handleClick, true);
106 | return () => {
107 | window.removeEventListener('click', handleClick, true);
108 | };
109 | }, [handleClick]);
110 | };
111 |
112 | const AppRender = () => {
113 | // console.log("加载 App");
114 | const { element } = useMouseStore();
115 |
116 | useMouseListener();
117 |
118 | return (
119 | <>
120 | {element ? (
121 |
153 |
154 |
155 |
156 |
157 |
158 | ) : null}
159 | >
160 | );
161 | };
162 |
--------------------------------------------------------------------------------
/entrypoints/hooks/use_dynamic_styles.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from "react";
2 |
3 | function useDynamicStyles() {
4 | // 用于存储动态 style 标签的引用
5 | const styleSheetRef = useRef(null);
6 |
7 | useEffect(() => {
8 | // 在组件挂载时创建一个 style 标签并插入到文档的 head 中
9 | const styleElement = document.createElement("style");
10 | styleElement.setAttribute("id", "dynamic-styles");
11 | document.head.appendChild(styleElement);
12 | styleSheetRef.current = styleElement.sheet as CSSStyleSheet;
13 |
14 | // 在组件卸载时移除 style 标签
15 | return () => {
16 | document.head.removeChild(styleElement);
17 | };
18 | }, []);
19 |
20 | // 检查是否存在某个样式规则
21 | const hasStyle = (className: string): boolean => {
22 | if (styleSheetRef.current) {
23 | const rules = styleSheetRef.current.cssRules;
24 | for (let i = 0; i < rules.length; i++) {
25 | const rule = rules[i] as CSSStyleRule;
26 | if (rule.selectorText === `.${className}`) {
27 | return true;
28 | }
29 | }
30 | }
31 | return false;
32 | };
33 |
34 | // 添加新的样式规则
35 | const addStyle = (className: string, rules: string) => {
36 | if (!hasStyle(className)) {
37 | if (styleSheetRef.current) {
38 | console.log("进行规则添加", className, rules);
39 | // CSS.escape 对 className 进行转义
40 | const escapedClassName = CSS.escape(className);
41 | const rule = `.${escapedClassName} { ${rules} }`;
42 | styleSheetRef.current.insertRule(rule, styleSheetRef.current.cssRules.length);
43 | }
44 | }
45 | };
46 |
47 | // 删除样式规则
48 | const removeStyle = (className: string) => {
49 | if (styleSheetRef.current) {
50 | const rules = styleSheetRef.current.cssRules;
51 | for (let i = 0; i < rules.length; i++) {
52 | const rule = rules[i] as CSSStyleRule;
53 | if (rule.selectorText === `.${className}`) {
54 | styleSheetRef.current.deleteRule(i);
55 | break;
56 | }
57 | }
58 | }
59 | };
60 |
61 | return { addStyle, removeStyle };
62 | }
63 |
64 | export default useDynamicStyles;
65 |
--------------------------------------------------------------------------------
/entrypoints/store/element_card.ts:
--------------------------------------------------------------------------------
1 | import { create } from 'zustand';
2 |
3 | //element窗口位置
4 | interface ElementCardStoreState {
5 | elementCardPosition: { top: number; left: number }
6 | setElementCardPosition: (top: number, left: number) => void;
7 | isMove: boolean;
8 | setIsMove: (isMove: boolean) => void;
9 | }
10 |
11 | export const useElementCardStore = create((set) => ({
12 | elementCardPosition: { top: 0, left: 0 },
13 | setElementCardPosition: (top, left) => set({ elementCardPosition: { top, left } }),
14 | isMove: false,
15 | setIsMove: (isMove) => set({ isMove })
16 | }));
--------------------------------------------------------------------------------
/entrypoints/store/mouse.ts:
--------------------------------------------------------------------------------
1 | import { create } from 'zustand';
2 |
3 | //鼠标相关
4 | interface MouseStoreState {
5 | element: HTMLElement | null
6 | setElement: (element: HTMLElement | null) => void
7 | lastEvent: MouseEvent
8 | setLastEvent: (event: MouseEvent) => void
9 | position: { x: number; y: number };
10 | setPosition: (x: number, y: number) => void;
11 | isListeningMouse: boolean;
12 | setIsListeningMouse: (isListening: boolean) => void;
13 | }
14 |
15 |
16 | export const useMouseStore = create((set) => ({
17 | element: null,
18 | setElement: (element: HTMLElement | null) => set({ element }),
19 | lastEvent: new MouseEvent("lastEvent"),
20 | setLastEvent: (event: MouseEvent) => set({ lastEvent: event }),
21 | position: { x: 0, y: 0 },
22 | setPosition: (x, y) => set({ position: { x, y } }),
23 | isListeningMouse: true,
24 | setIsListeningMouse: (isListening) => set({ isListeningMouse: isListening }),
25 | }));
26 |
27 |
--------------------------------------------------------------------------------
/entrypoints/store/rule.ts:
--------------------------------------------------------------------------------
1 | import { create } from 'zustand';
2 |
3 | //css 预设
4 | interface ruleStoreState {
5 | option: {
6 | label: string,
7 | value: string,
8 | desc: string,
9 | }[],
10 | setOption: (option: ruleStoreState['option']) => void,
11 | }
12 |
13 | export const useRuleStore = create((set) => ({
14 | option: [],
15 | setOption: (option) => set({ option }),
16 | }));
--------------------------------------------------------------------------------
/entrypoints/utlis/tools.tsx:
--------------------------------------------------------------------------------
1 | import { Breadcrumb } from "antd";
2 |
3 | /**
4 | * 获取元素的结构信息
5 | *
6 | * 该函数旨在提取给定HTML元素的标签名及其父元素的标签名如果存在的话
7 | * 它用于生成一个面包屑导航组件,显示元素的层级结构
8 | *
9 | * @param element HTML元素或null
10 | * @returns 如果元素无效,返回字符串'Invalid element';否则,返回面包屑导航组件或字符串,表示元素的结构信息
11 | */
12 | export const getElementStructure = (element: HTMLElement | null) => {
13 | if (!element || !(element instanceof HTMLElement)) {
14 | return 'Invalid element';
15 | }
16 | const currentTag = element.tagName.toLowerCase();
17 | const parentElement = element.parentElement;
18 | const parentTag = parentElement ? parentElement.tagName.toLowerCase() : '';
19 | if (parentTag) {
20 | return (
21 |
33 | )
34 | } else {
35 | return currentTag;
36 | }
37 | }
38 |
39 | /**
40 | * 从规则字符串中提取颜色信息
41 | *
42 | * 此函数用于解析给定的规则字符串(例如CSS规则),尝试从中提取出颜色值
43 | * 它会查找字符串中符合rgb、rgba或rgb(a)格式的颜色,并返回找到的颜色信息
44 | *
45 | * @param rule 待解析的规则字符串
46 | * @returns 如果找到颜色值,则返回一个包含isColor为true和颜色值color的对象;
47 | * 如果未找到颜色值,则返回一个包含isColor为false和color为null的对象
48 | */
49 | export const getColorFromRule = (rule: string) => {
50 | // 定义正则表达式,用于匹配rgb、rgba、rgb(a)格式的颜色字符串以及16进制颜色
51 | const colorRegex = /(?:rgb|rgba|rgb[a]?)\(([^)]+)\)|#([0-9a-fA-F]{3}){1,2}\b/i;
52 | const match = rule.match(colorRegex);
53 | if (match) {
54 | return { isColor: true, color: match[0] };
55 | }
56 | return { isColor: false, color: null };
57 | };
58 |
59 | /**
60 | * 计算滚动条的宽度
61 | *
62 | * 通过减去窗口的内宽度与文档元素的客户端宽度的差值来获取滚动条的宽度
63 | * 这个函数假设滚动条在右侧,并且在垂直滚动条显示时其宽度是恒定的
64 | *
65 | * @returns {number} 滚动条的宽度(以像素为单位)
66 | */
67 | export const getScrollbarWidth = (): number => {
68 | return window.innerWidth - document.documentElement.clientWidth;
69 | }
70 |
71 | /**
72 | * 检查两个 CSS 规则是否相同
73 | * @param rule1 第一个 CSS 规则字符串
74 | * @param rule2 第二个 CSS 规则字符串
75 | * @returns 如果两个规则相同返回 true,否则返回 false
76 | */
77 | export const areCSSRulesSame = (rule1: string, rule2: string): boolean => {
78 | console.log(rule1, rule2)
79 | // 解析 CSS 规则,提取属性名
80 | const parseCSSRule = (rule: string): Set =>
81 | new Set(
82 | rule.split(';')
83 | .map(line => line.split(':')[0]?.trim())
84 | .filter(Boolean) // 过滤掉空行和无效值
85 | );
86 | const properties1 = parseCSSRule(rule1);
87 | const properties2 = parseCSSRule(rule2);
88 | if (properties1.size !== properties2.size) return false;
89 | // 使用 every 检查所有属性名是否都在 properties2 中
90 | return [...properties1].every(property => properties2.has(property));
91 | };
92 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | import globals from "globals";
2 | import pluginJs from "@eslint/js";
3 | import tseslint from "@typescript-eslint";
4 | import pluginReact from "eslint-plugin-react";
5 |
6 | export default [{
7 | files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"],
8 | },
9 | {
10 | languageOptions: {
11 | globals: globals.browser,
12 | },
13 | },
14 | pluginJs.configs.recommended,
15 | ...tseslint.configs.recommended,
16 | pluginReact.configs.flat.recommended,
17 | {
18 | rules: {
19 | 'react/display-name': 'off',
20 | },
21 | },
22 | ];
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "tailexamine",
3 | "description": "manifest.json description",
4 | "private": true,
5 | "version": "0.0.1",
6 | "type": "module",
7 | "scripts": {
8 | "dev": "wxt",
9 | "dev:firefox": "wxt -b firefox",
10 | "build": "wxt build",
11 | "build:firefox": "wxt build -b firefox",
12 | "zip": "wxt zip",
13 | "zip:firefox": "wxt zip -b firefox",
14 | "compile": "tsc --noEmit",
15 | "postinstall": "wxt prepare"
16 | },
17 | "dependencies": {
18 | "antd": "^5.20.5",
19 | "radash": "^12.1.0",
20 | "react": "^18.3.1",
21 | "react-dom": "^18.3.1",
22 | "zustand": "^4.5.5"
23 | },
24 | "devDependencies": {
25 | "@ant-design/icons": "^5.4.0",
26 | "@iconify-json/system-uicons": "^1.1.13",
27 | "@types/chrome": "^0.0.270",
28 | "@types/react": "^18.3.3",
29 | "@types/react-dom": "^18.3.0",
30 | "@unocss/preset-icons": "^0.62.3",
31 | "@wxt-dev/module-react": "^1.1.0",
32 | "globals": "^15.9.0",
33 | "typescript": "^5.5.4",
34 | "unocss": "^0.62.2",
35 | "unocss-preset-scrollbar": "^0.3.1",
36 | "wxt": "^0.19.1"
37 | }
38 | }
--------------------------------------------------------------------------------
/public/css/element_info/index.css:
--------------------------------------------------------------------------------
1 | #element-info > .ant-card .ant-card-head {
2 | background-color: #1f2937;
3 | border-bottom: 0px !important;
4 | }
5 |
6 | #element-info > .ant-card .ant-card-body {
7 | padding: 0px !important;
8 | }
9 |
10 | #element-info .ant-checkbox-wrapper {
11 | color: #fff !important;
12 | }
13 |
14 | #element-info .ant-checkbox-group {
15 | margin-bottom: 20px;
16 | }
17 |
18 | #element-info .ant-checkbox-group {
19 | margin-right: 6px;
20 | margin-left: 6px;
21 | }
22 |
23 | #element-info .ant-tag {
24 | width: 100%;
25 | }
26 |
27 | .ant-select-dropdown {
28 | text-align: start !important;
29 | z-index: 99999 !important;
30 | }
31 |
32 |
--------------------------------------------------------------------------------
/public/icon/128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/suanju/tailexamine/89d7457f480bab9c993939797649e260525c1e63/public/icon/128.png
--------------------------------------------------------------------------------
/public/icon/16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/suanju/tailexamine/89d7457f480bab9c993939797649e260525c1e63/public/icon/16.png
--------------------------------------------------------------------------------
/public/icon/32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/suanju/tailexamine/89d7457f480bab9c993939797649e260525c1e63/public/icon/32.png
--------------------------------------------------------------------------------
/public/icon/48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/suanju/tailexamine/89d7457f480bab9c993939797649e260525c1e63/public/icon/48.png
--------------------------------------------------------------------------------
/public/icon/96.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/suanju/tailexamine/89d7457f480bab9c993939797649e260525c1e63/public/icon/96.png
--------------------------------------------------------------------------------
/public/wxt.svg:
--------------------------------------------------------------------------------
1 |
16 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./.wxt/tsconfig.json",
3 | "compilerOptions": {
4 | "allowImportingTsExtensions": true,
5 | "jsx": "react-jsx"
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/uno.config.ts:
--------------------------------------------------------------------------------
1 | // unocss.config.ts
2 | import { defineConfig, presetAttributify, presetUno } from 'unocss'
3 | import { presetScrollbar } from 'unocss-preset-scrollbar'
4 | import presetIcons from '@unocss/preset-icons'
5 |
6 | export default defineConfig({
7 | presets: [
8 | presetUno(),
9 | presetAttributify(),
10 | presetScrollbar({}),
11 | presetIcons({
12 | collections: {
13 | systemUicons: () => import('@iconify-json/system-uicons/icons.json').then((i) => i.default)
14 | }
15 | })
16 | ],
17 | })
--------------------------------------------------------------------------------
/wxt.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'wxt';
2 | import UnoCSS from 'unocss/vite'
3 |
4 | export default defineConfig({
5 | manifest: {
6 | action: {},
7 | page_action: {},
8 | },
9 | modules: ['@wxt-dev/module-react'],
10 | runner: {
11 | startUrls: ["https://www.tailwindcss.cn/"],
12 | },
13 | vite: () => {
14 | return {
15 | build: {
16 | sourcemap: false,
17 | }
18 | };
19 | },
20 | hooks: {
21 | "vite:build:extendConfig": (entries, config) => {
22 | const names = entries.map(entry => entry.name);
23 | if (names.includes("content")) config.plugins!.push(UnoCSS());
24 | },
25 | "vite:devServer:extendConfig": (config) => {
26 | config.plugins!.push(UnoCSS());
27 | },
28 | },
29 | });
30 |
--------------------------------------------------------------------------------