initial commit

This commit is contained in:
monoid 2025-02-03 02:17:10 +09:00
commit 9d4801e388
30 changed files with 11232 additions and 0 deletions

28
.gitignore vendored Normal file
View file

@ -0,0 +1,28 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Config files
.webextrc
.webextrc.*

9
LICENSE Normal file
View file

@ -0,0 +1,9 @@
The MIT License (MIT)
Copyright (c) 2025 - 2026, monoid
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

28
README.md Normal file
View file

@ -0,0 +1,28 @@
# Style Copy Browser Extension
This is a browser extension that allows you to copy the styles of any element on a webpage and paste them into your own CSS.
This is a work in progress and is not yet available for download.
## How to use
Open dev tools and click on an element. Sidebar will open with the styles of the element. Click on the copy button to copy the styles.
## Motivation
I wanted to create a tool that would allow me to easily copy the styles of an element on a webpage and paste them into my own CSS.
other tools 'divMagic' and 'CSS Peeper' are great but they are not open source and I wanted to create something that was open source and free to use.
## Tech Stack
- React
- TypeScript
- WebExtensions API
- Vite
## Development
1. Clone the repository
2. Run `pnpm install`
3. Run `pnpm run dev` to start the development server
4. browser will open with the extension installed

25
package.json Normal file
View file

@ -0,0 +1,25 @@
{
"name": "style-copy-extension",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.9",
"@types/webextension-polyfill": "^0.10.0",
"@vitejs/plugin-react": "^4.2.1",
"typescript": "~5.6.3",
"vite": "^5.0.0",
"vite-plugin-web-extension": "^4.0.0",
"webextension-polyfill": "^0.10.0"
},
"packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0"
}

2766
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,29 @@
<svg width="585" height="585" viewBox="0 0 585 585" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_2_13" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="585" height="585">
<path d="M585 292.5C585 454.043 454.043 585 292.5 585C130.957 585 0 454.043 0 292.5C0 130.957 130.957 0 292.5 0C454.043 0 585 130.957 585 292.5Z" fill="url(#paint0_radial_2_13)"/>
</mask>
<g mask="url(#mask0_2_13)">
<path d="M585 292.5C585 454.043 454.043 585 292.5 585C130.957 585 0 454.043 0 292.5C0 130.957 130.957 0 292.5 0C454.043 0 585 130.957 585 292.5Z" fill="url(#paint1_linear_2_13)"/>
</g>
<path fill-rule="evenodd" clip-rule="evenodd" d="M281 182C308.614 182 331 159.614 331 132L401 132C417.569 132 431 145.431 431 162V232.158C456.744 234.195 477 255.732 477 282C477 308.268 456.744 329.805 431 331.842V402C431 418.569 417.569 432 401 432H331C331 459.614 308.614 482 281 482C253.386 482 231 459.614 231 432H161C144.431 432 131 418.569 131 402L131 332C158.614 332 181 309.614 181 282C181 254.386 158.614 232 131 232L131 162C131 145.431 144.431 132 161 132L231 132C231 159.614 253.386 182 281 182Z" fill="url(#paint2_linear_2_13)"/>
<path d="M364.115 102.193L234.791 127.497C232.666 127.913 231.092 129.712 230.964 131.871L223.009 266.034C222.821 269.194 225.728 271.647 228.816 270.936L264.822 262.638C268.191 261.862 271.235 264.825 270.543 268.208L259.845 320.515C259.125 324.035 262.435 327.046 265.878 326.001L288.117 319.254C291.565 318.209 294.877 321.228 294.148 324.751L277.148 406.914C276.084 412.053 282.93 414.856 285.785 410.449L287.692 407.507L393.073 197.505C394.838 193.989 391.795 189.98 387.927 190.725L350.865 197.867C347.382 198.538 344.419 195.299 345.402 191.897L369.592 108.161C370.576 104.753 367.602 101.511 364.115 102.193Z" fill="url(#paint3_linear_2_13)"/>
<defs>
<radialGradient id="paint0_radial_2_13" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(292.958 292.042) rotate(90) scale(292.958 292.958)">
<stop/>
<stop offset="1" stop-opacity="0"/>
</radialGradient>
<linearGradient id="paint1_linear_2_13" x1="216.056" y1="140.528" x2="477.969" y2="374.671" gradientUnits="userSpaceOnUse">
<stop stop-color="#41D1FF"/>
<stop offset="1" stop-color="#BD34FE"/>
</linearGradient>
<linearGradient id="paint2_linear_2_13" x1="116.5" y1="223" x2="414.5" y2="482" gradientUnits="userSpaceOnUse">
<stop stop-color="#41D1FF"/>
<stop offset="1" stop-color="#BD34FE"/>
</linearGradient>
<linearGradient id="paint3_linear_2_13" x1="270.74" y1="109.063" x2="309.973" y2="378.586" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFEA83"/>
<stop offset="0.0833333" stop-color="#FFDD35"/>
<stop offset="1" stop-color="#FFA800"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

BIN
public/icon/128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
public/icon/16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 698 B

BIN
public/icon/32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
public/icon/48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

BIN
public/icon/96.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

33
src/background.ts Normal file
View file

@ -0,0 +1,33 @@
import browser from "webextension-polyfill";
console.log("Hello from the background!");
browser.runtime.onInstalled.addListener((details) => {
console.log("Extension installed:", details);
console.log("undefined",document);
});
browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
console.log("Got message", message);
if (message.type === "fetch") {
return ( async () => {
const response = await fetch(message.url);
if (!response.ok) {
console.error(`Failed to fetch: ${response.statusText}`);
throw new Error(`Failed to fetch: ${response.statusText}`);
}
if ( message.resType === "text" ) {
const text = await response.text();
return text;
}
else if ( message.resType === "json" ) {
const json = await response.json();
return json;
}
else if ( message.resType === "array" ) {
const array = await response.arrayBuffer();
return array;
}
})();
}
});

54
src/content.ts Normal file
View file

@ -0,0 +1,54 @@
import { cloneWithStyle, getInheritedStyle, styleFromCSSStyleRule } from "./lib/extract";
import browser from "webextension-polyfill";
console.log("Hello from content!");
browser.runtime.onMessage.addListener((event, sender, sendResponse) => {
console.log("Got message", event);
if (event.type != "cloneWithStyle") {
return;
}
console.log("Cloning with style");
const { elementSelector, id } = event;
try {
const element = document.querySelector(elementSelector) as HTMLElement;
if (!element) {
console.error("Element not found");
return;
}
return cloneWithStyle(element).then((vnode) => {
const inheritedStyle = getInheritedStyle(element);
const ret = {
type: "cloneWithStyleResult",
vnode,
inheritedStyle,
id
};
console.log("Sending response", ret);
return (ret);
});
}
catch (e) {
console.error("Error cloning with style", e);
}
});
// const functionCallTable = {
// cloneWithStyle,
// styleFromCSSStyleRule
// } as any;
// window.addEventListener("message", async (event) => {
// if (event.source !== window) {
// return;
// }
// if (event.data.type !== "functionCall") {
// return;
// }
// const { functionName, args, id } = event.data;
// if (!functionCallTable[functionName]) {
// console.error(`Function ${functionName} not found`);
// return;
// }
// const result = await functionCallTable[functionName](...args);
// window.postMessage({ type: "functionCallResult", result, id }, "*");
// });

13
src/devtools.html Normal file
View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DevTools</title>
</head>
<body>
<div id="app"></div>
test
<script type="module" src="./devtools.ts"></script>
</body>
</html>

7
src/devtools.ts Normal file
View file

@ -0,0 +1,7 @@
import browser from "webextension-polyfill";
console.log("Hello from devtools!");
browser.devtools.panels.elements.createSidebarPane("My Sidebar").then((sidebar) => {
sidebar.setPage("src/sidebar.html");
});

View file

@ -0,0 +1,388 @@
// Chrome default css properties
// TODO: Firefox, Edge, Safari, Opera, etc.
export const cssPropertiesDefault = {
"accent-color": "auto",
"align-content": "normal",
"align-items": "normal",
"align-self": "auto",
"alignment-baseline": "auto",
"anchor-name": "none",
"anchor-scope": "none",
"animation-composition": "replace",
"animation-delay": "0s",
"animation-direction": "normal",
"animation-duration": "0s",
"animation-fill-mode": "none",
"animation-iteration-count": "1",
"animation-name": "none",
"animation-play-state": "running",
"animation-range-end": "normal",
"animation-range-start": "normal",
"animation-timeline": "auto",
"animation-timing-function": "ease",
"app-region": "none",
"appearance": "none",
"backdrop-filter": "none",
"backface-visibility": "visible",
"background-attachment": "scroll",
"background-blend-mode": "normal",
"background-clip": "border-box",
"background-color": "rgba(0, 0, 0, 0)",
"background-image": "none",
"background-origin": "padding-box",
"background-position": "0% 0%",
"background-repeat": "repeat",
"background-size": "auto",
"baseline-shift": "0px",
"baseline-source": "auto",
"block-size": "0px",
"border-block-end-color": "rgb(0, 0, 0)",
"border-block-end-style": "none",
"border-block-end-width": "0px",
"border-block-start-color": "rgb(0, 0, 0)",
"border-block-start-style": "none",
"border-block-start-width": "0px",
"border-bottom-color": "rgb(0, 0, 0)",
"border-bottom-left-radius": "0px",
"border-bottom-right-radius": "0px",
"border-bottom-style": "none",
"border-bottom-width": "0px",
"border-collapse": "separate",
"border-end-end-radius": "0px",
"border-end-start-radius": "0px",
"border-image-outset": "0",
"border-image-repeat": "stretch",
"border-image-slice": "100%",
"border-image-source": "none",
"border-image-width": "1",
"border-inline-end-color": "rgb(0, 0, 0)",
"border-inline-end-style": "none",
"border-inline-end-width": "0px",
"border-inline-start-color": "rgb(0, 0, 0)",
"border-inline-start-style": "none",
"border-inline-start-width": "0px",
"border-left-color": "rgb(0, 0, 0)",
"border-left-style": "none",
"border-left-width": "0px",
"border-right-color": "rgb(0, 0, 0)",
"border-right-style": "none",
"border-right-width": "0px",
"border-start-end-radius": "0px",
"border-start-start-radius": "0px",
"border-top-color": "rgb(0, 0, 0)",
"border-top-left-radius": "0px",
"border-top-right-radius": "0px",
"border-top-style": "none",
"border-top-width": "0px",
"bottom": "auto",
"box-decoration-break": "slice",
"box-shadow": "none",
"box-sizing": "content-box",
"break-after": "auto",
"break-before": "auto",
"break-inside": "auto",
"buffered-rendering": "auto",
"caption-side": "top",
"caret-color": "rgb(0, 0, 0)",
"clear": "none",
"clip": "auto",
"clip-path": "none",
"clip-rule": "nonzero",
"color": "rgb(0, 0, 0)",
"color-interpolation": "srgb",
"color-interpolation-filters": "linearrgb",
"color-rendering": "auto",
"column-count": "auto",
"column-gap": "normal",
"column-rule-color": "rgb(0, 0, 0)",
"column-rule-style": "none",
"column-rule-width": "0px",
"column-span": "none",
"column-width": "auto",
"contain-intrinsic-block-size": "none",
"contain-intrinsic-height": "none",
"contain-intrinsic-inline-size": "none",
"contain-intrinsic-size": "none",
"contain-intrinsic-width": "none",
"container-name": "none",
"container-type": "normal",
"content": "normal",
"cursor": "auto",
"cx": "0px",
"cy": "0px",
"d": "none",
"direction": "ltr",
"display": "block",
"dominant-baseline": "auto",
"empty-cells": "show",
"field-sizing": "fixed",
"fill": "rgb(0, 0, 0)",
"fill-opacity": "1",
"fill-rule": "nonzero",
"filter": "none",
"flex-basis": "auto",
"flex-direction": "row",
"flex-grow": "0",
"flex-shrink": "1",
"flex-wrap": "nowrap",
"float": "none",
"flood-color": "rgb(0, 0, 0)",
"flood-opacity": "1",
"font-family": "\"Malgun Gothic\"",
"font-kerning": "auto",
"font-optical-sizing": "auto",
"font-palette": "normal",
"font-size": "16px",
"font-size-adjust": "none",
"font-stretch": "100%",
"font-style": "normal",
"font-synthesis-small-caps": "auto",
"font-synthesis-style": "auto",
"font-synthesis-weight": "auto",
"font-variant": "normal",
"font-variant-alternates": "normal",
"font-variant-caps": "normal",
"font-variant-east-asian": "normal",
"font-variant-emoji": "normal",
"font-variant-ligatures": "normal",
"font-variant-numeric": "normal",
"font-variant-position": "normal",
"font-weight": "400",
"grid-auto-columns": "auto",
"grid-auto-flow": "row",
"grid-auto-rows": "auto",
"grid-column-end": "auto",
"grid-column-start": "auto",
"grid-row-end": "auto",
"grid-row-start": "auto",
"grid-template-areas": "none",
"grid-template-columns": "none",
"grid-template-rows": "none",
"height": "0px",
"hyphenate-character": "auto",
"hyphenate-limit-chars": "auto",
"hyphens": "manual",
"image-orientation": "from-image",
"image-rendering": "auto",
"initial-letter": "normal",
"inline-size": "1904px",
"inset-block-end": "auto",
"inset-block-start": "auto",
"inset-inline-end": "auto",
"inset-inline-start": "auto",
"interpolate-size": "numeric-only",
"isolation": "auto",
"justify-content": "normal",
"justify-items": "normal",
"justify-self": "auto",
"left": "auto",
"letter-spacing": "normal",
"lighting-color": "rgb(255, 255, 255)",
"line-break": "auto",
"line-height": "normal",
"list-style-image": "none",
"list-style-position": "outside",
"list-style-type": "disc",
"margin-block-end": "0px",
"margin-block-start": "0px",
"margin-bottom": "0px",
"margin-inline-end": "0px",
"margin-inline-start": "0px",
"margin-left": "0px",
"margin-right": "0px",
"margin-top": "0px",
"marker-end": "none",
"marker-mid": "none",
"marker-start": "none",
"mask-clip": "border-box",
"mask-composite": "add",
"mask-image": "none",
"mask-mode": "match-source",
"mask-origin": "border-box",
"mask-position": "0% 0%",
"mask-repeat": "repeat",
"mask-size": "auto",
"mask-type": "luminance",
"math-depth": "0",
"math-shift": "normal",
"math-style": "normal",
"max-block-size": "none",
"max-height": "none",
"max-inline-size": "none",
"max-width": "none",
"min-block-size": "0px",
"min-height": "0px",
"min-inline-size": "0px",
"min-width": "0px",
"mix-blend-mode": "normal",
"object-fit": "fill",
"object-position": "50% 50%",
"object-view-box": "none",
"offset-anchor": "auto",
"offset-distance": "0px",
"offset-path": "none",
"offset-position": "normal",
"offset-rotate": "auto 0deg",
"opacity": "1",
"order": "0",
"orphans": "2",
"outline-color": "rgb(0, 0, 0)",
"outline-offset": "0px",
"outline-style": "none",
"outline-width": "0px",
"overflow-anchor": "auto",
"overflow-clip-margin": "0px",
"overflow-wrap": "normal",
"overflow-x": "visible",
"overflow-y": "visible",
"overlay": "none",
"overscroll-behavior-block": "auto",
"overscroll-behavior-inline": "auto",
"padding-block-end": "0px",
"padding-block-start": "0px",
"padding-bottom": "0px",
"padding-inline-end": "0px",
"padding-inline-start": "0px",
"padding-left": "0px",
"padding-right": "0px",
"padding-top": "0px",
"paint-order": "normal",
"perspective": "none",
"perspective-origin": "952px 0px",
"pointer-events": "auto",
"position": "static",
"position-anchor": "auto",
"position-area": "none",
"position-try-fallbacks": "none",
"position-try-order": "normal",
"position-visibility": "always",
"r": "0px",
"resize": "none",
"right": "auto",
"rotate": "none",
"row-gap": "normal",
"ruby-align": "space-around",
"ruby-position": "over",
"rx": "auto",
"ry": "auto",
"scale": "none",
"scroll-behavior": "auto",
"scroll-margin-block-end": "0px",
"scroll-margin-block-start": "0px",
"scroll-margin-inline-end": "0px",
"scroll-margin-inline-start": "0px",
"scroll-padding-block-end": "auto",
"scroll-padding-block-start": "auto",
"scroll-padding-inline-end": "auto",
"scroll-padding-inline-start": "auto",
"scroll-timeline-axis": "block",
"scroll-timeline-name": "none",
"scrollbar-color": "auto",
"scrollbar-gutter": "auto",
"scrollbar-width": "auto",
"shape-image-threshold": "0",
"shape-margin": "0px",
"shape-outside": "none",
"shape-rendering": "auto",
"speak": "normal",
"stop-color": "rgb(0, 0, 0)",
"stop-opacity": "1",
"stroke": "none",
"stroke-dasharray": "none",
"stroke-dashoffset": "0px",
"stroke-linecap": "butt",
"stroke-linejoin": "miter",
"stroke-miterlimit": "4",
"stroke-opacity": "1",
"stroke-width": "1px",
"tab-size": "8",
"table-layout": "auto",
"text-align": "start",
"text-align-last": "auto",
"text-anchor": "start",
"text-decoration": "none solid rgb(0, 0, 0)",
"text-decoration-color": "rgb(0, 0, 0)",
"text-decoration-line": "none",
"text-decoration-skip-ink": "auto",
"text-decoration-style": "solid",
"text-emphasis-color": "rgb(0, 0, 0)",
"text-emphasis-position": "over",
"text-emphasis-style": "none",
"text-indent": "0px",
"text-overflow": "clip",
"text-rendering": "auto",
"text-shadow": "none",
"text-size-adjust": "auto",
"text-spacing-trim": "normal",
"text-transform": "none",
"text-underline-position": "auto",
"text-wrap-mode": "wrap",
"text-wrap-style": "auto",
"timeline-scope": "none",
"top": "auto",
"touch-action": "auto",
"transform": "none",
"transform-origin": "952px 0px",
"transform-style": "flat",
"transition-behavior": "normal",
"transition-delay": "0s",
"transition-duration": "0s",
"transition-property": "all",
"transition-timing-function": "ease",
"translate": "none",
"unicode-bidi": "isolate",
"user-select": "auto",
"vector-effect": "none",
"vertical-align": "baseline",
"view-timeline-axis": "block",
"view-timeline-inset": "auto",
"view-timeline-name": "none",
"view-transition-class": "none",
"view-transition-name": "none",
"visibility": "visible",
"white-space-collapse": "collapse",
"widows": "2",
"width": "1904px",
"will-change": "auto",
"word-break": "normal",
"word-spacing": "0px",
"writing-mode": "horizontal-tb",
"x": "0px",
"y": "0px",
"z-index": "auto",
"zoom": "1",
"-webkit-border-horizontal-spacing": "0px",
"-webkit-border-image": "none",
"-webkit-border-vertical-spacing": "0px",
"-webkit-box-align": "stretch",
"-webkit-box-decoration-break": "slice",
"-webkit-box-direction": "normal",
"-webkit-box-flex": "0",
"-webkit-box-ordinal-group": "1",
"-webkit-box-orient": "horizontal",
"-webkit-box-pack": "start",
"-webkit-box-reflect": "none",
"-webkit-font-smoothing": "auto",
"-webkit-line-break": "auto",
"-webkit-line-clamp": "none",
"-webkit-locale": "\"en\"",
"-webkit-mask-box-image": "none",
"-webkit-mask-box-image-outset": "0",
"-webkit-mask-box-image-repeat": "stretch",
"-webkit-mask-box-image-slice": "0 fill",
"-webkit-mask-box-image-source": "none",
"-webkit-mask-box-image-width": "auto",
"-webkit-print-color-adjust": "economy",
"-webkit-rtl-ordering": "logical",
"-webkit-tap-highlight-color": "rgba(0, 0, 0, 0.18)",
"-webkit-text-combine": "none",
"-webkit-text-decorations-in-effect": "none",
"-webkit-text-fill-color": "rgb(0, 0, 0)",
"-webkit-text-orientation": "vertical-right",
"-webkit-text-security": "none",
"-webkit-text-stroke-color": "rgb(0, 0, 0)",
"-webkit-text-stroke-width": "0px",
"-webkit-user-drag": "auto",
"-webkit-user-modify": "read-only",
"-webkit-writing-mode": "horizontal-tb"
}

313
src/lib/extract.ts Normal file
View file

@ -0,0 +1,313 @@
import browser from "webextension-polyfill";
// TODO: check if this is the correct way to calculate specificity. css parser might be better
function calculateSpecificity(selector: string): [number, number, number] {
// 결과를 저장할 Specificity 배열: [ID, Class/Attribute/Pseudo-class, Tag/Pseudo-element]
const specificity = [0, 0, 0] as [number, number, number];
// 정규식을 사용하여 선택자에서 ID, 클래스/속성/가상 클래스, 태그/가상 요소를 분리
const regex = {
id: /#[\w-]+/g, // ID 선택자
classAttrPseudo: /\.[\w-]+|\[[^\]]+\]|:[^\s>+~.#\[:]+/g, // 클래스, 속성 선택자, 가상 클래스
tagPseudoElement: /^[a-z]+|::[\w-]+/gi // 태그 이름 또는 가상 요소 (::before, ::after 등)
};
// ID 선택자 개수 계산
const idMatches = selector.match(regex.id);
if (idMatches) {
specificity[0] += idMatches.length;
}
// 클래스, 속성 선택자 및 가상 클래스 개수 계산
const classAttrPseudoMatches = selector.match(regex.classAttrPseudo);
if (classAttrPseudoMatches) {
specificity[1] += classAttrPseudoMatches.length;
}
// 태그 및 가상 요소 개수 계산
const tagPseudoElementMatches = selector.match(regex.tagPseudoElement);
if (tagPseudoElementMatches) {
specificity[2] += tagPseudoElementMatches.length;
}
return specificity;
}
async function getCssRules(sheet: CSSStyleSheet) {
try {
return sheet.cssRules;
} catch (error) {
return null;
}
}
async function getCssRulesCORS(sheet: CSSStyleSheet) {
const r = await getCssRules(sheet);
if (r) {
return r;
}
try {
// TODO: refactor this to extract the fetch function
const text = await browser.runtime.sendMessage({ type: "fetch", url: sheet.href, resType: "text" });
console.log("Fetched CSS", text.slice(0, 100));
const styleElem = document.createElement("style");
styleElem.textContent = text;
// TODO: check if this is the correct way.
// 스타일을 넣지 않으면 sheet가 생성되지 않음.
// 브라우저 엔진 말고 파싱 라이브러리 사용해서 성능 테스트 필요
document.head.appendChild(styleElem);
const rules = styleElem.sheet?.cssRules;
document.head.removeChild(styleElem);
return rules;
} catch (error) {
console.error("Error fetching CSS", error);
return null;
}
}
class StyleStore {
private store = new Map<string, { rule: CSSStyleRule, specificity: [number, number, number] }>();
public add(rule: CSSStyleRule, order = 0) {
this.store.set(rule.selectorText, {
rule,
specificity: calculateSpecificity(rule.selectorText)
});
}
public get(selector: string) {
return this.store.get(selector);
}
public getAll() {
return this.store;
}
static async fromDocument() {
const store = new StyleStore();
const sheets = document.styleSheets;
for (let i = 0; i < sheets.length; i++) {
let sheet = sheets[i];
let rules = await getCssRulesCORS(sheet as CSSStyleSheet);
if (rules) {
for (let j = 0; j < rules.length; j++) {
const rule = rules[j];
if (rule instanceof CSSStyleRule) {
const styleRule = rule;
store.add(styleRule);
}
else if (rule instanceof CSSMediaRule) {
const conditionText = rule.conditionText;
// assume all media rules are screen or bigger than 1024px
if (!conditionText || window.matchMedia(conditionText).matches) {
const mediaRules = rule.cssRules;
if (mediaRules) {
for (let k = 0; k < mediaRules.length; k++) {
const mediaStyleRule = mediaRules[k];
if (mediaStyleRule instanceof CSSStyleRule) {
store.add(mediaStyleRule);
}
}
}
}
}
}
}
}
return store;
}
}
let styleStore: StyleStore | null = null;
async function getStoreSingleton() {
if (!styleStore) {
styleStore = await StyleStore.fromDocument();
}
return styleStore;
}
async function getAllAppliedRuleOfElement(element: Element) {
const store = await getStoreSingleton();
const rules = [] as { rule: CSSStyleRule, specificity: [number, number, number], selectorText: string }[];
for (const [selector, { rule, specificity }] of store.getAll()) {
if (element.matches(selector)) {
rules.push({
rule,
specificity,
selectorText: selector
});
}
}
rules.sort((a, b) => {
if (a.specificity[0] !== b.specificity[0]) {
return a.specificity[0] - b.specificity[0];
} else if (a.specificity[1] !== b.specificity[1]) {
return a.specificity[1] - b.specificity[1];
} else {
return a.specificity[2] - b.specificity[2];
}
});
return rules;
}
export type VElementNode = {
type: "element";
tagName: string;
style: { [key: string]: string };
children: VNode[];
}
export type VTextNode = {
type: "text";
text: string;
}
export type VNode = VElementNode | VTextNode;
export function styleFromCSSStyleRule(rule: CSSStyleRule) {
const style = {} as { [key: string]: string };
for (let i = 0; i < rule.style.length; i++) {
const property = rule.style.item(i);
style[property] = rule.style.getPropertyValue(property);
}
return style;
}
import { cssPropertiesDefault } from "./CSSPropertiesDefaultValue";
export async function cloneWithStyle(element: HTMLElement): Promise<VNode> {
const dom = {
type: "element",
tagName: element.tagName.toLowerCase(),
style: {} as { [key: string]: string },
children: [] as VNode[]
} as VElementNode;
const rules = await getAllAppliedRuleOfElement(element);
// rules.reverse();
// const computedStyle = getComputedStyle(element);
const cssInheritanceSet = new Set(cssInheritanceProperties);
for (const { rule } of rules) {
const style = styleFromCSSStyleRule(rule);
for (const property in style) {
const value = style[property];
if (value === "initial" && (!cssInheritanceSet.has(property))) {
continue;
}
if (cssPropertiesDefault[property as keyof typeof cssPropertiesDefault] === value && (!cssInheritanceSet.has(property))) {
continue;
}
dom.style[property] = style[property];
}
}
element.style.cssText.split(";")
.filter((property) => property.trim())
.forEach((property) => {
const [key, value] = property.split(":");
dom.style[key.trim()] = value.trim();
});
for (let i = 0; i < element.childNodes.length; i++) {
const child = element.childNodes[i];
if (child.nodeType === Node.ELEMENT_NODE) {
dom.children.push(await cloneWithStyle(child as HTMLElement));
}
else if (child.nodeType === Node.TEXT_NODE) {
const text = child.textContent?.trim();
if (text) {
dom.children.push({
type: "text",
text
});
}
}
}
return dom;
}
import { generatedProperties } from "./supportedCSSProperties";
const cssInheritanceProperties = (() => {
const inherit = generatedProperties.filter(p => p.inherited)
const longhands = new Set<string>();
for (const property of inherit) {
if (property.longhands) {
property.longhands.forEach(longhand => longhands.add(longhand));
}
}
return inherit.filter(p => !longhands.has(p.name))
.map(p => p.name)
.filter(p => !p.startsWith("-webkit-"));
// TODO: remove this filter after fixing the issue with -webkit- properties
})();
export function getInheritedStyle(element: HTMLElement) {
const style = {} as { [key: string]: string };
const computedStyle = window.getComputedStyle(element);
for (const property of cssInheritanceProperties) {
style[property] = computedStyle.getPropertyValue(property);
}
return style;
}
// function getComputedStyleWithoutDefault(element) {
// const inheritedProperties = new Set([
// "azimuth",
// "border-collapse",
// "border-spacing",
// "caption-side",
// "color",
// "cursor",
// "direction",
// "elevation",
// "empty-cells",
// "font-family",
// "font-size",
// "font-style",
// "font-variant",
// "font-weight",
// "font",
// "letter-spacing",
// "line-height",
// "list-style-image",
// "list-style-position",
// "list-style-type",
// "list-style",
// "orphans",
// "pitch-range",
// "pitch",
// "quotes",
// "richness",
// "speak-header",
// "speak-numeral",
// "speak-punctuation",
// "speak",
// "speech-rate",
// "stress",
// "text-align",
// "text-indent",
// "text-transform",
// "visibility",
// "voice-family",
// "volume",
// "white-space",
// "widows",
// "word-spacing",
// ]);
// const computedStyle = window.getComputedStyle(element);
// const parentComputedStyle = window.getComputedStyle(element.parentElement);
// const style = {};
// // TODO: add element's style to style
// const defaultStyle = window.getComputedStyle(document.documentElement);
// for (let i = 0; i < computedStyle.length; i++) {
// const property = computedStyle[i];
// if (inheritedProperties.has(property)) {
// if (computedStyle[property] !== parentComputedStyle[property]) {
// style[property] = computedStyle[property];
// }
// } else {
// // if default value is not equal to computed value then add it to style
// // remove default value like 0px, 0em, 0%, none, normal, auto
// const value = computedStyle.getPropertyValue(property);
// if (value !== defaultStyle.getPropertyValue(property)) {
// style[property] = value;
// }
// }
// }
// return style;
// }

File diff suppressed because it is too large Load diff

32
src/manifest.json Normal file
View file

@ -0,0 +1,32 @@
{
"{{chrome}}.manifest_version": 3,
"{{firefox}}.manifest_version": 3,
"icons": {
"16": "icon/16.png",
"32": "icon/32.png",
"48": "icon/48.png",
"96": "icon/96.png",
"128": "icon/128.png"
},
"{{chrome}}.action": {
"default_popup": "src/popup.html"
},
"devtools_page": "src/devtools.html",
"sidebar_action": {
"default_panel": "src/sidebar.html",
"default_title": "My Sidebar"
},
"{{firefox}}.browser_action": {
"default_popup": "src/popup.html"
},
"host_permissions": ["<all_urls>"],
"background": {
"service_worker": "src/background.ts"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["src/content.ts"]
}
]
}

33
src/pages/Popup.css Normal file
View file

@ -0,0 +1,33 @@
div {
height: 100%;
display: flex;
flex-direction: column;
gap: 16px;
align-items: center;
justify-content: center;
}
img {
width: 200px;
height: 200px;
}
h1 {
font-size: 18px;
color: white;
font-weight: bold;
margin: 0;
}
p {
color: white;
opacity: 0.7;
margin: 0;
}
code {
font-size: 12px;
padding: 2px 4px;
background-color: #ffffff24;
border-radius: 2px;
}

18
src/pages/Popup.tsx Normal file
View file

@ -0,0 +1,18 @@
import { useEffect } from 'react';
import "./Popup.css";
export default function() {
useEffect(() => {
console.log("Hello from the popup!");
}, []);
return (
<div>
<img src="/icon-with-shadow.svg" />
<h1>vite-plugin-web-extension</h1>
<p>
Template: <code>react-ts</code>
</p>
</div>
)
}

11
src/popup.css Normal file
View file

@ -0,0 +1,11 @@
html,
body {
width: 300px;
height: 400px;
padding: 0;
margin: 0;
}
body {
background-color: rgb(36, 36, 36);
}

14
src/popup.html Normal file
View file

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="./popup.css" />
<title>Popup</title>
</head>
<body>
<div id="app"></div>
</body>
<script type="module" src="./popup.tsx"></script>
</html>

9
src/popup.tsx Normal file
View file

@ -0,0 +1,9 @@
import React from "react";
import ReactDOM from "react-dom/client";
import Popup from "./pages/Popup";
ReactDOM.createRoot(document.querySelector("#app")!).render(
<React.StrictMode>
<Popup />
</React.StrictMode>
);

19
src/sidebar.html Normal file
View file

@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sidebar</title>
<style>
code {
font-family: monospace;
background: #f0f0f0;
display: block;
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="./sidebar.tsx"></script>
</body>
</html>

352
src/sidebar.tsx Normal file
View file

@ -0,0 +1,352 @@
import React from "react";
import ReactDOM from "react-dom/client";
import browser from "webextension-polyfill";
import type { VNode } from "./lib/extract";
import { cssPropertiesDefault } from "./lib/CSSPropertiesDefaultValue";
console.log("Hello from sidebar!");
function getUniqueSelector(el: Element) {
if (!(el instanceof Element))
throw new Error("Invalid argument");
var path = [];
while (el.nodeType === Node.ELEMENT_NODE) {
let selector = el.nodeName.toLowerCase();
const parentElement = el.parentElement;
if (el.id) {
selector += '#' + el.id;
path.unshift(selector);
break;
}
if (!parentElement) {
break;
}
let nth = 1;
let sib = el.previousElementSibling;
while (sib) {
nth++;
sib = sib.previousElementSibling;
}
selector += ":nth-child(" + nth + ")";
path.unshift(selector);
el = parentElement;
}
return path.join(" > ");
}
const code = `
(() => {
let getUniqueSelector = ${getUniqueSelector.toString()};
return getUniqueSelector($0);
})()
`
function useSelectedElement() {
const [element, setElement] = React.useState<{
vnode: VNode,
inheritedStyle: { [key: string]: string }
} | null>();
React.useEffect(() => {
const handler = async () => {
const id = Math.floor(Math.random() * 1000000);
// get selector of the selected element
console.log("evaluating code");
const [selector, isException] = await browser.devtools.inspectedWindow.eval(code);
if (isException) {
console.error("Error evaluating code", isException);
return;
}
console.log(selector);
const tab = await browser.tabs.query({ active: true, currentWindow: true });
const tabId = browser.devtools.inspectedWindow.tabId;
console.log("sending message", tabId);
// post message to content script to get vnode of the selected element
const res = await browser.tabs.sendMessage(tabId, { type: "cloneWithStyle", elementSelector: selector, id });
console.log("got response", res);
if (!res) {
console.error("No response");
return;
}
if (res.type !== "cloneWithStyleResult") {
console.error("Unexpected response", res);
return;
}
console.log("got response");
setElement({
vnode: res.vnode,
inheritedStyle: res.inheritedStyle
});
};
if (!element) {
handler();
}
browser.devtools.panels.elements.onSelectionChanged.addListener(handler);
return () => {
browser.devtools.panels.elements.onSelectionChanged.removeListener(handler);
};
}, []);
return element;
}
function useGetComputedStyle() {
const [styles, setStyles] = React.useState<{ [key: string]: string } | null>(null);
React.useEffect(() => {
const handler = async () => {
const [styles, isException] = await browser.devtools.inspectedWindow.eval(`
(() => {
const element = $0;
const style = getComputedStyle(element);
const styles = {};
for (let i = 0; i < style.length; i++) {
const property = style.item(i);
styles[property] = style.getPropertyValue(property);
}
return styles;
})()
`);
if (isException) {
console.error("Error evaluating code", isException);
return;
}
let newStyles = {} as { [key: string]: string };
for (const key in styles) {
if (key in cssPropertiesDefault) {
const value = cssPropertiesDefault[key as keyof typeof cssPropertiesDefault];
if (styles[key] != value) {
newStyles[key] = value;
}
}
else {
newStyles[key] = styles[key];
}
}
setStyles(newStyles);
};
if (!styles) {
handler();
}
browser.devtools.panels.elements.onSelectionChanged.addListener(handler);
return () => {
browser.devtools.panels.elements.onSelectionChanged.removeListener(handler);
};
}, []);
return styles;
}
function kebabToCamel(str: string) {
return str.replace(/-./g, (match) => match[1].toUpperCase());
}
function htmlStyleToReactStyle(style: { [key: string]: string }) {
return Object.fromEntries(Object.entries(style).map(([key, value]) => [kebabToCamel(key), value]));
}
const HTMLComponentTable: { [key: string]: keyof JSX.IntrinsicElements } = {
"a": "a",
"abbr": "abbr",
"address": "address",
"area": "area",
"article": "article",
"aside": "aside",
// "audio": "audio",
"b": "b",
"base": "base",
"bdi": "bdi",
"bdo": "bdo",
"big": "big",
"blockquote": "blockquote",
"body": "body",
"br": "br",
"button": "button",
"canvas": "canvas",
"caption": "caption",
"center": "center",
"cite": "cite",
"code": "code",
"col": "col",
"colgroup": "colgroup",
"data": "data",
"datalist": "datalist",
"dd": "dd",
"del": "del",
"details": "details",
"dfn": "dfn",
"dialog": "dialog",
"div": "div",
"dl": "dl",
"dt": "dt",
"em": "em",
"embed": "embed",
"fieldset": "fieldset",
"figcaption": "figcaption",
"figure": "figure",
"footer": "footer",
"form": "form",
"h1": "h1",
"h2": "h2",
"h3": "h3",
"h4": "h4",
"h5": "h5",
"h6": "h6",
// "head": "head",
"header": "header",
"hgroup": "hgroup",
"hr": "hr",
// "html": "html",
"i": "i",
// "iframe": "iframe",
// "img": "img",
"input": "input",
"ins": "ins",
"kbd": "kbd",
"keygen": "keygen",
"label": "label",
"legend": "legend",
"li": "li",
"link": "link",
"main": "main",
"map": "map",
"mark": "mark",
"menu": "menu",
"menuitem": "menuitem",
// "meta": "meta",
"meter": "meter",
"nav": "nav",
"noindex": "noindex",
"noscript": "noscript",
"object": "object",
"ol": "ol",
"optgroup": "optgroup",
"option": "option",
"output": "output",
"p": "p",
"param": "param",
"picture": "picture",
"pre": "pre",
"progress": "progress",
"q": "q",
"rp": "rp",
"rt": "rt",
"ruby": "ruby",
"s": "s",
"samp": "samp",
"search": "search",
"slot": "slot",
"script": "script",
"section": "section",
"select": "select",
"small": "small",
"source": "source",
"span": "span",
"strong": "strong",
"style": "style",
"sub": "sub",
"summary": "summary",
"sup": "sup",
"table": "table",
"template": "template",
"tbody": "tbody",
"td": "td",
"textarea": "textarea",
"tfoot": "tfoot",
"th": "th",
"thead": "thead",
"time": "time",
"title": "title",
"tr": "tr",
"track": "track",
"u": "u",
"ul": "ul",
"var": "var",
// "video": "video",
"wbr": "wbr",
"webview": "webview",
}
function VnodeToReact({ vnode }: { vnode: VNode }): React.ReactNode {
if (vnode.type === "text") {
return vnode.text;
}
else if (vnode.type === "element") {
// TODO: support svg
const Tag = HTMLComponentTable[vnode.tagName] ?? "div";
const style = vnode.style;
const children = vnode.children;
return <Tag style={htmlStyleToReactStyle(style)}>
{children.map((child, i) => <VnodeToReact key={i} vnode={child} />)}
</Tag>
}
}
function escapseAttrValue(value: string) {
return value.replaceAll('"', '&quot;')
}
function VnodeToHTML({ vnode }: { vnode: VNode }): string {
if (vnode.type === "text") {
return vnode.text;
}
else if (vnode.type === "element") {
const Tag = vnode.tagName;
const style = vnode.style;
const children = vnode.children;
return `<${Tag} style="${Object.entries(style).map(([key, value]) => `${key}: ${escapseAttrValue(value)}`).join(";")}">
${children.map((child, i) => VnodeToHTML({ vnode: child })).join("")}
</${Tag}>`
}
return "";
}
function inheritStyleToStyleText(inheritedStyle: { [key: string]: string }) {
return Object.entries(inheritedStyle).map(([key, value]) => `${key}: ${value}`).join(";\n");
}
function Content() {
const data = useSelectedElement();
const inheritedStyleText = data ? inheritStyleToStyleText(data.inheritedStyle) : null;
const html = data ? VnodeToHTML({ vnode: data?.vnode }) : "";
return <div>
<h1>Vnode</h1>
<code
lang="json"
style={{
width: "100%",
height: "4rem",
overflow: "auto",
position: "relative"
}}
>
{inheritedStyleText}
</code>
<div>
<code lang="html"
style={{
width: "100%",
height: "20rem",
overflow: "auto",
position: "relative"
}}
>
{html}
</code>
{data &&
<div style={data.inheritedStyle}>
<VnodeToReact vnode={data.vnode} />
</div>
}
</div>
</div>;
}
ReactDOM.createRoot(document.querySelector("#app")!).render(
<React.StrictMode>
<Content />
</React.StrictMode>
);

1
src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

21
tsconfig.json Normal file
View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

9
tsconfig.node.json Normal file
View file

@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

24
vite.config.ts Normal file
View file

@ -0,0 +1,24 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import webExtension, { readJsonFile } from "vite-plugin-web-extension";
function generateManifest() {
const manifest = readJsonFile("src/manifest.json");
const pkg = readJsonFile("package.json");
return {
name: pkg.name,
description: pkg.description,
version: pkg.version,
...manifest,
};
}
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
react(),
webExtension({
manifest: generateManifest,
}),
],
});