diff --git a/projects/web-filter/package.json b/projects/web-filter/package.json index 3f32d50..a0672e3 100644 --- a/projects/web-filter/package.json +++ b/projects/web-filter/package.json @@ -13,6 +13,7 @@ "react-dom": "^18.2.0" }, "devDependencies": { + "@types/chrome": "^0.0.283", "@types/react": "^18.0.26", "@types/react-dom": "^18.0.9", "@types/webextension-polyfill": "^0.10.0", diff --git a/projects/web-filter/src/background.ts b/projects/web-filter/src/background.ts index f25472a..0a51771 100644 --- a/projects/web-filter/src/background.ts +++ b/projects/web-filter/src/background.ts @@ -5,3 +5,14 @@ console.log('Hello from the background!'); browser.runtime.onInstalled.addListener((details) => { console.log('Extension installed:', details); }); + +chrome.runtime.onMessage.addListener((message, _, sendResponse) => { + try { + if (message.tabId && message.action) { + chrome.tabs.sendMessage(message.tabId, { ...message }); + } + sendResponse({ success: true }); + } catch (error) { + sendResponse({ success: false, error }); + } +}); diff --git a/projects/web-filter/src/components/Button/Button.tsx b/projects/web-filter/src/components/Button/Button.tsx index 3e7eeca..930d843 100644 --- a/projects/web-filter/src/components/Button/Button.tsx +++ b/projects/web-filter/src/components/Button/Button.tsx @@ -4,10 +4,13 @@ import styles from './Button.module.css'; type ButtonProps = PropsWithChildren<{ theme?: 'blue' | 'gray'; + onClick?: () => void; }>; -export const Button = ({ children, theme = 'blue' }: ButtonProps) => { +export const Button = ({ children, theme = 'blue', onClick }: ButtonProps) => { return ( - + ); }; diff --git a/projects/web-filter/src/content.tsx b/projects/web-filter/src/content.tsx index b1b6ea9..415ccc5 100644 --- a/projects/web-filter/src/content.tsx +++ b/projects/web-filter/src/content.tsx @@ -1,3 +1,39 @@ -import { popup } from './functions/popup'; +import { createRoot } from 'react-dom/client'; -popup.open(); +import { VintageFilter } from './filters/VintageFilter'; +import { WaveFilter } from './filters/WaveFilter'; +import { ElementSelector } from './scripts-lib/element-selector'; +import { ACTION } from './types/status'; + +// TODO: 탭 간 이동 시 초기화 +const selector = new ElementSelector(); + +chrome.runtime.onMessage.addListener(async (request) => { + switch (request.action) { + case ACTION.START_SELECT_ELEMENT: + selector.surfingElements(); + break; + case ACTION.APPLY_FILTER: + selector.applyFilter(request.filterId); + break; + default: + break; + } +}); + +export default function Content() { + return ( + + + + + + + ); +} + +const app = document.createElement('div'); +app.id = 'web-filter-app'; +app.style.display = 'none'; +document.body.appendChild(app); +createRoot(app).render(); diff --git a/projects/web-filter/src/filters/VintageFilter.tsx b/projects/web-filter/src/filters/VintageFilter.tsx new file mode 100644 index 0000000..c880d41 --- /dev/null +++ b/projects/web-filter/src/filters/VintageFilter.tsx @@ -0,0 +1,40 @@ +export function VintageFilter() { + return ( + + {/* 그레인 효과 추가 */} + + + + + {/* 원본 이미지의 투명도 조절 */} + + {/* 투명도 50% */} + + + + + ); +} diff --git a/projects/web-filter/src/filters/WaveFilter.tsx b/projects/web-filter/src/filters/WaveFilter.tsx index a3bf260..91008cb 100644 --- a/projects/web-filter/src/filters/WaveFilter.tsx +++ b/projects/web-filter/src/filters/WaveFilter.tsx @@ -1,7 +1,7 @@ export function WaveFilter() { return ( "], + "js": ["src/content.tsx"], + "all_frames": true + } + ], + "permissions": ["activeTab", "tabs"], + "host_permissions": [""] } diff --git a/projects/web-filter/src/pages/PopupNative/PopupNative.tsx b/projects/web-filter/src/pages/PopupNative/PopupNative.tsx index 27b4bfa..3b1aeb3 100644 --- a/projects/web-filter/src/pages/PopupNative/PopupNative.tsx +++ b/projects/web-filter/src/pages/PopupNative/PopupNative.tsx @@ -1,28 +1,64 @@ import { Button } from '../../components/Button/Button'; import { Header } from '../../components/Header/Header'; +import { ACTION } from '../../types/status'; import styles from './PopupNative.module.css'; export const PopupNative = () => { + const handleStartSelectElement = async () => { + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + const tabId = tabs[0]?.id; + + if (tabId) { + await chrome.runtime.sendMessage({ + tabId, + action: ACTION.START_SELECT_ELEMENT, + }); + } + }; + + const handleFilterClick = async (filterId: string) => { + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + const tabId = tabs[0]?.id; + const action = ACTION.APPLY_FILTER; + + if (tabId) { + await chrome.runtime.sendMessage({ tabId, action, filterId }); + } + }; + return (
- {['적록색맹', '필름카메라', '유리창', '빛 번짐', '기타'].map( - (filterName) => ( -
- -

{filterName}

-
- ), - )} + {[ + { + filterId: 'wave', + filterName: '파도', + }, + { + filterId: 'vintage', + filterName: '빈티지', + }, + ].map(({ filterId, filterName }) => ( +
handleFilterClick(filterId)} + > + +

{filterName}

+
+ ))}
); diff --git a/projects/web-filter/src/scripts-lib/element-selector.styles.ts b/projects/web-filter/src/scripts-lib/element-selector.styles.ts new file mode 100644 index 0000000..6171609 --- /dev/null +++ b/projects/web-filter/src/scripts-lib/element-selector.styles.ts @@ -0,0 +1,134 @@ +export const overlayStyles = { + base: ` + position: fixed; + box-sizing: border-box; + transition: all 0.2s ease-in-out; + border-radius: 4px; + z-index: 10000; + pointer-events: none; + `, + + themes: { + default: { + style: ` + background: linear-gradient(45deg, + rgba(255, 255, 255, 0.1), + rgba(255, 255, 255, 0.2) + ); + border: 3px solid transparent; + box-shadow: + 0 0 0 2px rgba(62, 184, 255, 0.4), + 0 0 10px rgba(62, 184, 255, 0.3), + inset 0 0 20px rgba(62, 184, 255, 0.2); + `, + keyframes: ` + @keyframes pulse { + 0% { + box-shadow: + 0 0 0 2px rgba(62, 184, 255, 0.4), + 0 0 10px rgba(62, 184, 255, 0.3), + inset 0 0 20px rgba(62, 184, 255, 0.2); + } + 50% { + box-shadow: + 0 0 0 4px rgba(62, 184, 255, 0.4), + 0 0 15px rgba(62, 184, 255, 0.3), + inset 0 0 30px rgba(62, 184, 255, 0.2); + } + 100% { + box-shadow: + 0 0 0 2px rgba(62, 184, 255, 0.4), + 0 0 10px rgba(62, 184, 255, 0.3), + inset 0 0 20px rgba(62, 184, 255, 0.2); + } + } + `, + }, + + pastel: { + style: ` + background: linear-gradient(45deg, + rgba(255, 182, 193, 0.2), + rgba(255, 218, 185, 0.2), + rgba(255, 255, 224, 0.2), + rgba(176, 224, 230, 0.2) + ); + border: 2px solid rgba(255, 182, 193, 0.3); + box-shadow: + 0 0 10px rgba(255, 182, 193, 0.2), + inset 0 0 20px rgba(176, 224, 230, 0.2); + `, + keyframes: ` + @keyframes pulse { + 0% { + box-shadow: + 0 0 10px rgba(255, 182, 193, 0.2), + inset 0 0 20px rgba(176, 224, 230, 0.2); + } + 50% { + box-shadow: + 0 0 15px rgba(255, 218, 185, 0.3), + inset 0 0 25px rgba(176, 224, 230, 0.3); + } + 100% { + box-shadow: + 0 0 10px rgba(255, 182, 193, 0.2), + inset 0 0 20px rgba(176, 224, 230, 0.2); + } + } + `, + }, + + neon: { + style: ` + background: rgba(0, 0, 0, 0.1); + border: 2px solid #00ff00; + box-shadow: + 0 0 10px #00ff00, + inset 0 0 20px rgba(0, 255, 0, 0.5); + `, + keyframes: ` + @keyframes pulse { + 0% { + box-shadow: + 0 0 10px #00ff00, + 0 0 20px #00ff00, + 0 0 30px #00ff00, + inset 0 0 20px rgba(0, 255, 0, 0.5); + } + 50% { + box-shadow: + 0 0 15px #00ff00, + 0 0 25px #00ff00, + 0 0 35px #00ff00, + inset 0 0 25px rgba(0, 255, 0, 0.7); + } + 100% { + box-shadow: + 0 0 10px #00ff00, + 0 0 20px #00ff00, + 0 0 30px #00ff00, + inset 0 0 20px rgba(0, 255, 0, 0.5); + } + } + `, + }, + + minimal: { + style: ` + background: rgba(0, 0, 0, 0.05); + border: 2px solid rgba(0, 0, 0, 0.1); + box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); + `, + keyframes: ` + @keyframes pulse { + 0% { opacity: 0.7; } + 50% { opacity: 1; } + 100% { opacity: 0.7; } + } + `, + }, + }, +} as const; + +export type OverlayTheme = keyof typeof overlayStyles.themes; diff --git a/projects/web-filter/src/scripts-lib/element-selector.ts b/projects/web-filter/src/scripts-lib/element-selector.ts new file mode 100644 index 0000000..c3eafa2 --- /dev/null +++ b/projects/web-filter/src/scripts-lib/element-selector.ts @@ -0,0 +1,152 @@ +import { STATUS } from '../types/status'; +import { + ThemeType, + cleanupOverlayElement, + createOverlayElement, + getThemeColor, + highlightElement, + updateOverlayStyle, +} from './utils'; + +// TODO: 리스너 해제 연결, 초기화 +// TODO: selected element 정보 popup에 전달 🚨 (for preview) +// TODO: 화면에서 기존 요소와의 인터랙션을 불가능하게 하기 🚨 +// TODO: 탭간 이동시 초기화 🚨 + +export class ElementSelector { + private OVERLAY_ID = 'web_filter_overlay_element'; + + private status: STATUS = STATUS.INACTIVE; + private selectedElement: HTMLElement | null = null; + private styleSheet: HTMLStyleElement | null = null; + private currentTheme: ThemeType = 'default'; + private overlay: HTMLElement | null = null; + + constructor() { + this.bindEvents(); + } + + private setStatus(status: STATUS) { + this.status = status; + } + + private createOverlay = () => { + // 기존 오버레이 제거 + if (this.overlay && document.body.contains(this.overlay)) { + document.body.removeChild(this.overlay); + } + + const overlay = createOverlayElement(this.OVERLAY_ID); + this.styleSheet = updateOverlayStyle( + overlay, + this.currentTheme, + this.styleSheet, + ); + + this.overlay = overlay; + return overlay; + }; + + /** + * @description + * + * event handlers + */ + + private handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + this.cleanupOverlay(); + this.setStatus(STATUS.INACTIVE); + this.selectedElement = null; + } + }; + + private handleScroll = () => { + if (this.overlay && this.selectedElement) { + const rect = this.selectedElement.getBoundingClientRect(); + this.overlay.style.left = `${rect.left}px`; + this.overlay.style.top = `${rect.top}px`; + this.overlay.style.width = `${rect.width}px`; + this.overlay.style.height = `${rect.height}px`; + } + }; + + private handleMouseMove = (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + if (this.status !== STATUS.SURFING) return; + + const element = document.elementFromPoint(e.clientX, e.clientY); + if (!element) return; + + const overlay = + document.getElementById(this.OVERLAY_ID) || this.createOverlay(); + + const rect = element.getBoundingClientRect(); + overlay.style.left = `${rect.left}px`; + overlay.style.top = `${rect.top}px`; + overlay.style.width = `${rect.width}px`; + overlay.style.height = `${rect.height}px`; + + highlightElement(overlay); + }; + + private handleMouseDown = (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const element = document.elementFromPoint(e.clientX, e.clientY); + if (!element || !(element instanceof HTMLElement)) return; + + if (this.selectedElement && this.selectedElement !== element) { + this.selectedElement = null; + this.setStatus(STATUS.SURFING); + return; + } + + this.selectedElement = element; + this.setStatus(STATUS.SELECTED); + + if (this.overlay) { + this.overlay.style.border = + '2px solid ' + getThemeColor(this.currentTheme); + } + }; + + private bindEvents = () => { + window.addEventListener('scroll', this.handleScroll); + window.addEventListener('keydown', this.handleKeyDown); + window.addEventListener('mousedown', this.handleMouseDown); + window.addEventListener('mousemove', this.handleMouseMove); + }; + + /** + * @description + * + * public methods + */ + public surfingElements = () => { + this.setStatus(STATUS.SURFING); + }; + + public applyFilter(filterId: string) { + if (!this.selectedElement) return; + this.selectedElement.style.filter = `url(#${filterId})`; + } + + public cleanupOverlay = () => { + cleanupOverlayElement(this.styleSheet, this.overlay); + this.styleSheet = null; + this.overlay = null; + }; + + public destroy = () => { + this.cleanupOverlay(); + + window.removeEventListener('scroll', this.handleScroll); + window.removeEventListener('keydown', this.handleKeyDown); + window.removeEventListener('mousedown', this.handleMouseDown); + window.removeEventListener('mousemove', this.handleMouseMove); + }; +} diff --git a/projects/web-filter/src/scripts-lib/utils/cleanup-overlay-element.ts b/projects/web-filter/src/scripts-lib/utils/cleanup-overlay-element.ts new file mode 100644 index 0000000..70cfbdf --- /dev/null +++ b/projects/web-filter/src/scripts-lib/utils/cleanup-overlay-element.ts @@ -0,0 +1,12 @@ +export const cleanupOverlayElement = ( + styleSheet: HTMLStyleElement | null, + overlay: HTMLElement | null, +): void => { + if (styleSheet && document.head.contains(styleSheet)) { + document.head.removeChild(styleSheet); + } + + if (overlay && document.body.contains(overlay)) { + document.body.removeChild(overlay); + } +}; diff --git a/projects/web-filter/src/scripts-lib/utils/create-overlay-element.ts b/projects/web-filter/src/scripts-lib/utils/create-overlay-element.ts new file mode 100644 index 0000000..85a8ce6 --- /dev/null +++ b/projects/web-filter/src/scripts-lib/utils/create-overlay-element.ts @@ -0,0 +1,6 @@ +export const createOverlayElement = (overlayId: string): HTMLElement => { + const overlay = document.createElement('div'); + overlay.id = overlayId; + document.body.appendChild(overlay); + return overlay; +}; diff --git a/projects/web-filter/src/scripts-lib/utils/get-theme-color.ts b/projects/web-filter/src/scripts-lib/utils/get-theme-color.ts new file mode 100644 index 0000000..dc06803 --- /dev/null +++ b/projects/web-filter/src/scripts-lib/utils/get-theme-color.ts @@ -0,0 +1,14 @@ +import { ThemeType } from '.'; + +export const getThemeColor = (currentTheme: ThemeType): string => { + switch (currentTheme) { + case 'pastel': + return 'rgba(255, 182, 193, 0.5)'; + case 'neon': + return '#00ff00'; + case 'minimal': + return 'rgba(0, 0, 0, 0.2)'; + default: + return 'rgba(62, 184, 255, 0.5)'; + } +}; diff --git a/projects/web-filter/src/scripts-lib/utils/hightlight-element.ts b/projects/web-filter/src/scripts-lib/utils/hightlight-element.ts new file mode 100644 index 0000000..1ccb57a --- /dev/null +++ b/projects/web-filter/src/scripts-lib/utils/hightlight-element.ts @@ -0,0 +1,4 @@ +export const highlightElement = (overlay: HTMLElement): void => { + overlay.style.transform = 'scale(1.01)'; + setTimeout(() => (overlay.style.transform = 'scale(1)'), 300); +}; diff --git a/projects/web-filter/src/scripts-lib/utils/index.ts b/projects/web-filter/src/scripts-lib/utils/index.ts new file mode 100644 index 0000000..3aa1858 --- /dev/null +++ b/projects/web-filter/src/scripts-lib/utils/index.ts @@ -0,0 +1,16 @@ +import { overlayStyles } from '../element-selector.styles'; +import { cleanupOverlayElement } from './cleanup-overlay-element'; +import { createOverlayElement } from './create-overlay-element'; +import { getThemeColor } from './get-theme-color'; +import { highlightElement } from './hightlight-element'; +import { updateOverlayStyle } from './update-overlay-style'; + +export type ThemeType = keyof typeof overlayStyles.themes; + +export { + createOverlayElement, + cleanupOverlayElement, + highlightElement, + updateOverlayStyle, + getThemeColor, +}; diff --git a/projects/web-filter/src/scripts-lib/utils/update-overlay-style.ts b/projects/web-filter/src/scripts-lib/utils/update-overlay-style.ts new file mode 100644 index 0000000..8dd302f --- /dev/null +++ b/projects/web-filter/src/scripts-lib/utils/update-overlay-style.ts @@ -0,0 +1,26 @@ +import { ThemeType } from '.'; +import { overlayStyles } from '../element-selector.styles'; + +export const updateOverlayStyle = ( + overlay: HTMLElement, + currentTheme: ThemeType, + styleSheet: HTMLStyleElement | null, +): HTMLStyleElement => { + // 기존 스타일시트 제거 + if (styleSheet && document.head.contains(styleSheet)) { + document.head.removeChild(styleSheet); + } + + const theme = overlayStyles.themes[currentTheme]; + + // 새 스타일시트 생성 및 적용 + const newStyleSheet = document.createElement('style'); + newStyleSheet.textContent = theme.keyframes; + document.head.appendChild(newStyleSheet); + + // 오버레이에 스타일 적용 + overlay.style.cssText = overlayStyles.base + theme.style; + overlay.style.animation = 'pulse 2s infinite'; + + return newStyleSheet; +}; diff --git a/projects/web-filter/src/types/status.ts b/projects/web-filter/src/types/status.ts new file mode 100644 index 0000000..181a9a6 --- /dev/null +++ b/projects/web-filter/src/types/status.ts @@ -0,0 +1,10 @@ +export enum STATUS { + INACTIVE = 'INACTIVE', // 선택기가 비활성화된 상태 + SURFING = 'SURFING', // 마우스가 움직이면서 요소를 탐색 중인 상태 + SELECTED = 'SELECTED', // 요소가 선택된 상태 +} + +export enum ACTION { + START_SELECT_ELEMENT = 'START_SELECT_ELEMENT', + APPLY_FILTER = 'APPLY_FILTER', +} diff --git a/yarn.lock b/yarn.lock index e34b355..8ab738a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2294,6 +2294,16 @@ __metadata: languageName: node linkType: hard +"@types/chrome@npm:^0.0.283": + version: 0.0.283 + resolution: "@types/chrome@npm:0.0.283" + dependencies: + "@types/filesystem": "npm:*" + "@types/har-format": "npm:*" + checksum: 49bb67c0b78517319de00c0c3c0e0ce06b9acad56064044953e6b2a31eff8878e4fd23605865c3986eeba82723724375be7abf5976b256dc08373e390ed51341 + languageName: node + linkType: hard + "@types/css-font-loading-module@npm:^0.0.12": version: 0.0.12 resolution: "@types/css-font-loading-module@npm:0.0.12" @@ -2322,6 +2332,29 @@ __metadata: languageName: node linkType: hard +"@types/filesystem@npm:*": + version: 0.0.36 + resolution: "@types/filesystem@npm:0.0.36" + dependencies: + "@types/filewriter": "npm:*" + checksum: ec831040fe3aff066ffb7b7541e21a5dd59aa06e7175c61e592736e38b018b1d513551438254631e2a3fbc81ff671bf618401000f4c8ea79156934cbc7dcaeaa + languageName: node + linkType: hard + +"@types/filewriter@npm:*": + version: 0.0.33 + resolution: "@types/filewriter@npm:0.0.33" + checksum: 495a4bb424c27eda967fe9ac3b8f7b781e6b3f9ce59403a991590cb1073022f9c5383d3c7d808ef6956b785550c36664c4fcd502dc0baf69e340bd481171e0ca + languageName: node + linkType: hard + +"@types/har-format@npm:*": + version: 1.2.16 + resolution: "@types/har-format@npm:1.2.16" + checksum: b7ecef1ca27b902f9eb0bff9cebe650370f594e20813a728853673b22400afa08966eb5fd725553c19811bc166947e1c845e92ce4df86cee79d4fd9bda4d251b + languageName: node + linkType: hard + "@types/http-cache-semantics@npm:^4.0.2": version: 4.0.4 resolution: "@types/http-cache-semantics@npm:4.0.4" @@ -8675,6 +8708,7 @@ __metadata: version: 0.0.0-use.local resolution: "web-filter@workspace:projects/web-filter" dependencies: + "@types/chrome": "npm:^0.0.283" "@types/react": "npm:^18.0.26" "@types/react-dom": "npm:^18.0.9" "@types/webextension-polyfill": "npm:^0.10.0"