├── .prettierignore ├── sample.html ├── public ├── images │ ├── icon-128.png │ ├── icon-48.png │ ├── reload-128.png │ └── release-note-128.png ├── test-fullstop.html ├── test-SentenceSegregation.html ├── test-FirstCharRect.html ├── release-note.html ├── popup.css ├── test-scroll.html └── popup.html ├── .prettierrc ├── src ├── config │ ├── ColorConfigItem.ts │ ├── DropdownConfigItem.ts │ ├── ConfigManager.ts │ ├── NumberConfigItem.ts │ └── Config.ts ├── releaseNote.ts ├── FocusInfo.ts ├── Fragment.ts ├── Point.ts ├── draw │ ├── Drawer.ts │ ├── DrawStrategy.enum.ts │ ├── DrawOption.ts │ ├── UnderlineDrawer.ts │ ├── FirstCharOutlineDrawer.ts │ ├── FirstCharHighlighterDrawer.ts │ ├── FixedUnderlineDrawer.ts │ ├── OutlineDrawer.ts │ ├── HighlighterDrawer.ts │ ├── SpotlightDrawer.ts │ ├── BracketDrawer.ts │ └── MergedOutlineDrawer.ts ├── DelimitPattern.ts ├── AnchorDrawInfo.ts ├── SimpleDelimitPattern.ts ├── background.ts ├── Utils.ts ├── Stack.ts ├── Anchor.ts ├── Rect.ts ├── debug.ts ├── Renderer.ts ├── content.ts ├── main.ts └── FocusManager.ts ├── tsconfig.json ├── .gitignore ├── manifest.json ├── package.json ├── webpack.config.js ├── README.md └── LICENSE /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | build 4 | coverage 5 | -------------------------------------------------------------------------------- /sample.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |

a. b

5 | 6 | 7 | -------------------------------------------------------------------------------- /public/images/icon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hamsteak1488/focus-anchor/HEAD/public/images/icon-128.png -------------------------------------------------------------------------------- /public/images/icon-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hamsteak1488/focus-anchor/HEAD/public/images/icon-48.png -------------------------------------------------------------------------------- /public/images/reload-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hamsteak1488/focus-anchor/HEAD/public/images/reload-128.png -------------------------------------------------------------------------------- /public/images/release-note-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hamsteak1488/focus-anchor/HEAD/public/images/release-note-128.png -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "tabWidth": 2, 5 | "trailingComma": "all", 6 | "printWidth": 100, 7 | "endOfLine": "lf" 8 | } 9 | -------------------------------------------------------------------------------- /public/test-fullstop.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |

1. one. 2. t2w2o. 3. three3. 4f. four. 55. fifty five. end

5 | 6 | 7 | -------------------------------------------------------------------------------- /src/config/ColorConfigItem.ts: -------------------------------------------------------------------------------- 1 | export class ColorConfigItem { 2 | selected: string; 3 | 4 | constructor(selected: string) { 5 | this.selected = selected; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/releaseNote.ts: -------------------------------------------------------------------------------- 1 | export async function getReleaseNoteHtml(): Promise { 2 | const response = await fetch(chrome.runtime.getURL('release-note.html')); 3 | return await response.text(); 4 | } 5 | -------------------------------------------------------------------------------- /src/FocusInfo.ts: -------------------------------------------------------------------------------- 1 | export class FocusInfo { 2 | nodeIdx: number; 3 | anchorIdx: number; 4 | 5 | constructor(nodeIdx: number, anchorIdx: number) { 6 | this.nodeIdx = nodeIdx; 7 | this.anchorIdx = anchorIdx; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/Fragment.ts: -------------------------------------------------------------------------------- 1 | export class Fragment { 2 | ch: string; 3 | node: Node; 4 | idx: number; 5 | 6 | constructor(ch: string, node: Node, idx: number) { 7 | this.ch = ch; 8 | this.node = node; 9 | this.idx = idx; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/config/DropdownConfigItem.ts: -------------------------------------------------------------------------------- 1 | export class DropdownConfigItem { 2 | selected: T; 3 | options: T[]; 4 | 5 | constructor(selected: T, options: T[]) { 6 | this.selected = selected; 7 | this.options = options; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/Point.ts: -------------------------------------------------------------------------------- 1 | export class Point { 2 | x: number; 3 | y: number; 4 | 5 | constructor(x: number, y: number) { 6 | this.x = x; 7 | this.y = y; 8 | } 9 | 10 | static add(u: Point, v: Point): Point { 11 | return new Point(u.x + v.x, u.y + v.y); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/draw/Drawer.ts: -------------------------------------------------------------------------------- 1 | import { AnchorDrawInfo } from '../AnchorDrawInfo'; 2 | import { Renderer } from '../Renderer'; 3 | import { DrawOption } from './DrawOption'; 4 | 5 | export interface Drawer { 6 | draw(renderer: Renderer, anchorDrawInfo: AnchorDrawInfo, drawOption: DrawOption): void; 7 | } 8 | -------------------------------------------------------------------------------- /public/test-SentenceSegregation.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |

5 | 데이터베이스를 어디까지 알아야하나요?? 6 | 데이터베이스를 어떻게 활용하는 것이 효율적인가요? 쿼리를 어떻게 만들고 8 | 튜닝해야할까요? 10 |

11 | 12 | 13 | -------------------------------------------------------------------------------- /src/DelimitPattern.ts: -------------------------------------------------------------------------------- 1 | export class DelimitPattern { 2 | test: (str: string) => boolean; 3 | exclusionCountFromEnd: number; 4 | 5 | constructor(test: (str: string) => boolean, exclusionCountFromEnd: number) { 6 | this.test = test; 7 | this.exclusionCountFromEnd = exclusionCountFromEnd; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/AnchorDrawInfo.ts: -------------------------------------------------------------------------------- 1 | import { Rect } from './Rect'; 2 | 3 | export class AnchorDrawInfo { 4 | sentenceRects: Rect[]; 5 | firstCharRect: Rect | null; 6 | 7 | constructor(sentenceRects: Rect[], firstCharRect: Rect | null) { 8 | this.sentenceRects = sentenceRects; 9 | this.firstCharRect = firstCharRect; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/config/ConfigManager.ts: -------------------------------------------------------------------------------- 1 | import { Config } from './Config'; 2 | 3 | export class ConfigManager { 4 | private static instance: Config; 5 | 6 | private constructor() {} 7 | 8 | static getInstance(): Config { 9 | if (!ConfigManager.instance) { 10 | ConfigManager.instance = new Config(); 11 | } 12 | return ConfigManager.instance; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "lib": ["DOM", "ES2020"], 5 | "module": "CommonJS", 6 | "moduleResolution": "Node", 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "skipLibCheck": true, 10 | "outDir": "dist", 11 | "types": ["chrome", "node", "jsdom"] 12 | }, 13 | "include": ["src/**/*"] 14 | } 15 | -------------------------------------------------------------------------------- /src/SimpleDelimitPattern.ts: -------------------------------------------------------------------------------- 1 | import { DelimitPattern } from './DelimitPattern'; 2 | 3 | export class SimpleDelimitPattern extends DelimitPattern { 4 | regexp: RegExp; 5 | 6 | constructor(regexp: RegExp, exclusionCountFromEnd: number) { 7 | super((str: string) => { 8 | return regexp.test(str); 9 | }, exclusionCountFromEnd); 10 | this.regexp = regexp; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/draw/DrawStrategy.enum.ts: -------------------------------------------------------------------------------- 1 | export enum DrawStrategy { 2 | Underline = 'Underline', 3 | FixedUnderline = 'FixedUnderline', 4 | Outline = 'Outline', 5 | MergedOutline = 'MergedOutline', 6 | Spotlight = 'Spotlight', 7 | FirstCharOutline = 'FirstCharOutline', 8 | Highlighter = 'Highlighter', 9 | FirstCharHighlighter = 'FirstCharHighlighter', 10 | Bracket = 'Bracket', 11 | } 12 | -------------------------------------------------------------------------------- /public/test-FirstCharRect.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |

5 | 이 과정에서 JVM이 이해할 수 있는 바이트 코드로 변환되어 6 | .class 파일이 생성됩니다. 이후부터는 JVM이 담당하는데요. 먼저 7 | 클래스 로더(Class Loader) 가 바이트 코드를 JVM 메모리에 동적으로 로드합니다. 8 | 로드된 바이트 코드는 Method Area에 저장되며, 이때 로딩(Loading), 링킹(Linking), 9 | 초기화(Initialization) 단계를 거칩니다. 10 |

11 | 12 | 13 | -------------------------------------------------------------------------------- /src/background.ts: -------------------------------------------------------------------------------- 1 | chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { 2 | if (msg.type === 'request-toggle-focus') { 3 | if (sender.tab && sender.tab.id) { 4 | chrome.tabs.sendMessage(sender.tab.id, { type: 'toggle-focus' }); 5 | } 6 | } 7 | }); 8 | 9 | chrome.runtime.onInstalled.addListener((details) => { 10 | if (details.reason === 'update') { 11 | chrome.storage.local.set({ releaseNoteChecked: false }); 12 | } 13 | }); 14 | -------------------------------------------------------------------------------- /src/config/NumberConfigItem.ts: -------------------------------------------------------------------------------- 1 | export class NumberConfigItem { 2 | value: number; 3 | /** There is no limit if null. */ 4 | minValue: number | null; 5 | /** There is no limit if null. */ 6 | maxValue: number | null; 7 | 8 | /** If there is no min or max limit, enter null there. */ 9 | constructor(value: number, minValue: number | null, maxValue: number | null) { 10 | this.value = value; 11 | this.minValue = minValue; 12 | this.maxValue = maxValue; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Utils.ts: -------------------------------------------------------------------------------- 1 | import { Point } from './Point'; 2 | 3 | export class Utils { 4 | static clamp(value: number, min: number | null, max: number | null) { 5 | if (min != null) { 6 | value = Math.max(min, value); 7 | } 8 | if (max != null) { 9 | value = Math.min(max, value); 10 | } 11 | 12 | return value; 13 | } 14 | 15 | static getVectorLength(u: Point, v: Point): number { 16 | const dx = v.x - u.x; 17 | const dy = v.y - u.y; 18 | 19 | return Math.sqrt(dx * dx + dy * dy); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Stack.ts: -------------------------------------------------------------------------------- 1 | export class Stack { 2 | private _data: T[] = []; 3 | 4 | constructor() {} 5 | 6 | push(item: T): void { 7 | this._data.push(item); 8 | } 9 | 10 | pop(): T { 11 | const top = this._data[this._data.length - 1]; 12 | this._data.pop(); 13 | return top; 14 | } 15 | 16 | peek(): T { 17 | return this._data[this._data.length - 1]; 18 | } 19 | 20 | isEmpty(): boolean { 21 | return this._data.length === 0; 22 | } 23 | 24 | size(): number { 25 | return this._data.length; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # ───────────── 기본 Node/TypeScript ───────────── 2 | node_modules/ 3 | npm-debug.log* 4 | yarn-debug.log* 5 | pnpm-lock.yaml 6 | 7 | # 환경 변수 8 | .env 9 | .env.* 10 | 11 | # ───────────── 빌드/번들 산출물 ───────────── 12 | dist/ 13 | coverage/ 14 | *.tgz 15 | 16 | # ───────────── OS / Editor 잡파일 ───────────── 17 | .DS_Store 18 | Thumbs.db 19 | 20 | # VS Code 설정(공유 원치 않을 때) 21 | .vscode/ 22 | 23 | # JetBrains IDE (WebStorm, IntelliJ 등) 24 | .idea/ 25 | 26 | # Mac 사용자 캐시 27 | *.swp 28 | .svn 29 | 30 | # ───────────── Chrome Extension 특화 ───────────── 31 | # Chrome이 동적으로 생성하는 임시 파일 32 | *_locales/*.json 33 | # 퍼펫티어·Playwright 다운로드 34 | .chromium/ 35 | 36 | *.zip 37 | .gemini/ -------------------------------------------------------------------------------- /src/Anchor.ts: -------------------------------------------------------------------------------- 1 | export class Anchor { 2 | startNodeIdx: number; 3 | startOffsetIdx: number; 4 | endNodeIdx: number; 5 | endOffsetIdx: number; // endOffsetIdx는 exclusive. 6 | 7 | constructor( 8 | startNodeIdx: number, 9 | startOffsetIdx: number, 10 | endNodeIdx: number, 11 | endOffsetIdx: number, 12 | ) { 13 | this.startNodeIdx = startNodeIdx; 14 | this.startOffsetIdx = startOffsetIdx; 15 | this.endNodeIdx = endNodeIdx; 16 | this.endOffsetIdx = endOffsetIdx; 17 | } 18 | 19 | toString(): string { 20 | return `start:[node=${this.startNodeIdx}, offset=${this.startOffsetIdx}], end:[node=${this.endNodeIdx}, offset=${this.endOffsetIdx}]`; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Rect.ts: -------------------------------------------------------------------------------- 1 | export class Rect { 2 | x: number; 3 | y: number; 4 | width: number; 5 | height: number; 6 | 7 | constructor(x: number, y: number, width: number, height: number) { 8 | this.x = x; 9 | this.y = y; 10 | this.width = width; 11 | this.height = height; 12 | } 13 | 14 | get left(): number { 15 | return this.x; 16 | } 17 | get top(): number { 18 | return this.y; 19 | } 20 | get right(): number { 21 | return this.x + this.width; 22 | } 23 | get bottom(): number { 24 | return this.y + this.height; 25 | } 26 | 27 | toString(): string { 28 | return `[x=${this.x}, y=${this.y}, width=${this.width}, height=${this.height}]`; 29 | } 30 | 31 | static from(rect: Rect): Rect { 32 | return new Rect(rect.x, rect.y, rect.width, rect.height); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/draw/DrawOption.ts: -------------------------------------------------------------------------------- 1 | import { colord } from 'colord'; 2 | 3 | export class DrawOption { 4 | /** RGB color */ 5 | rgbColor: string; 6 | /** range: [0, 100] */ 7 | opacityRatio: number; 8 | /** range: [0, ∞] */ 9 | lineWidth: number; 10 | /** range: [0, 100] */ 11 | radiusRatio: number; 12 | 13 | constructor(rgbColor: string, opacityRatio: number, lineWidth: number, radiusRatio: number) { 14 | this.rgbColor = rgbColor; 15 | this.opacityRatio = opacityRatio; 16 | this.lineWidth = lineWidth; 17 | this.radiusRatio = radiusRatio; 18 | } 19 | 20 | /** range: [0.0, 1.0] */ 21 | get alpha(): number { 22 | console.debug(); 23 | return this.opacityRatio / 100; 24 | } 25 | 26 | get rgba(): string { 27 | return colord(this.rgbColor).alpha(this.alpha).toRgbString(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Focus Anchor", 3 | "description": "Software to anchor focus.", 4 | "version": "1.6.0", 5 | "manifest_version": 3, 6 | "icons": { 7 | "48": "images/icon-48.png", 8 | "128": "images/icon-128.png" 9 | }, 10 | "background": { 11 | "service_worker": "background.js" 12 | }, 13 | "permissions": ["storage"], 14 | "host_permissions": [""], 15 | "action": { 16 | "default_popup": "popup.html", 17 | "default_icon": { 18 | "48": "images/icon-48.png" 19 | } 20 | }, 21 | "content_scripts": [ 22 | { 23 | "matches": [""], 24 | "js": ["content.js"], 25 | "run_at": "document_idle", 26 | "all_frames": true 27 | } 28 | ], 29 | "content_security_policy": { 30 | "extension_pages": "script-src 'self'; object-src 'self'" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/draw/UnderlineDrawer.ts: -------------------------------------------------------------------------------- 1 | import { AnchorDrawInfo } from '../AnchorDrawInfo'; 2 | import { ConfigManager } from '../config/ConfigManager'; 3 | import { Point } from '../Point'; 4 | import { Renderer } from '../Renderer'; 5 | import { Drawer } from './Drawer'; 6 | import { DrawOption } from './DrawOption'; 7 | 8 | export class UnderlineDrawer implements Drawer { 9 | draw(renderer: Renderer, anchorDrawInfo: AnchorDrawInfo, drawOption: DrawOption): void { 10 | const config = ConfigManager.getInstance(); 11 | 12 | for (const rect of anchorDrawInfo.sentenceRects) { 13 | const polygonVertices: Point[] = []; 14 | polygonVertices.push( 15 | new Point(rect.left, rect.bottom + config.paddingY.value), 16 | new Point(rect.right, rect.bottom + config.paddingY.value), 17 | ); 18 | renderer.drawPolygon(polygonVertices, drawOption); 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/draw/FirstCharOutlineDrawer.ts: -------------------------------------------------------------------------------- 1 | import { AnchorDrawInfo } from '../AnchorDrawInfo'; 2 | import { ConfigManager } from '../config/ConfigManager'; 3 | import { Rect } from '../Rect'; 4 | import { Renderer } from '../Renderer'; 5 | import { Drawer } from './Drawer'; 6 | import { DrawOption } from './DrawOption'; 7 | 8 | export class FirstCharOutlineDrawer implements Drawer { 9 | draw(renderer: Renderer, anchorDrawInfo: AnchorDrawInfo, drawOption: DrawOption): void { 10 | const config = ConfigManager.getInstance(); 11 | 12 | if (!anchorDrawInfo.firstCharRect) return; 13 | const marginAppliedRect = Rect.from(anchorDrawInfo.firstCharRect); 14 | 15 | marginAppliedRect.x -= config.paddingX.value; 16 | marginAppliedRect.y -= config.paddingY.value; 17 | marginAppliedRect.width += config.paddingX.value * 2; 18 | marginAppliedRect.height += config.paddingY.value * 2; 19 | 20 | renderer.drawRect(marginAppliedRect, drawOption); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/draw/FirstCharHighlighterDrawer.ts: -------------------------------------------------------------------------------- 1 | import { colord } from 'colord'; 2 | import { AnchorDrawInfo } from '../AnchorDrawInfo'; 3 | import { ConfigManager } from '../config/ConfigManager'; 4 | import { Rect } from '../Rect'; 5 | import { Renderer } from '../Renderer'; 6 | import { Drawer } from './Drawer'; 7 | import { DrawOption } from './DrawOption'; 8 | 9 | export class FirstCharHighlighterDrawer implements Drawer { 10 | draw(renderer: Renderer, anchorDrawInfo: AnchorDrawInfo, drawOption: DrawOption): void { 11 | const config = ConfigManager.getInstance(); 12 | 13 | if (!anchorDrawInfo.firstCharRect) return; 14 | const marginAppliedRect = Rect.from(anchorDrawInfo.firstCharRect); 15 | 16 | marginAppliedRect.x -= config.paddingX.value; 17 | marginAppliedRect.y -= config.paddingY.value; 18 | marginAppliedRect.width += config.paddingX.value * 2; 19 | marginAppliedRect.height += config.paddingY.value * 2; 20 | 21 | renderer.fillRect(marginAppliedRect, drawOption); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/draw/FixedUnderlineDrawer.ts: -------------------------------------------------------------------------------- 1 | import { AnchorDrawInfo } from '../AnchorDrawInfo'; 2 | import { ConfigManager } from '../config/ConfigManager'; 3 | import { Point } from '../Point'; 4 | import { Renderer } from '../Renderer'; 5 | import { Drawer } from './Drawer'; 6 | import { DrawOption } from './DrawOption'; 7 | 8 | export class FixedUnderlineDrawer implements Drawer { 9 | draw(renderer: Renderer, anchorDrawInfo: AnchorDrawInfo, drawOption: DrawOption): void { 10 | const config = ConfigManager.getInstance(); 11 | 12 | const polygonVertices: Point[] = []; 13 | polygonVertices.push( 14 | new Point( 15 | anchorDrawInfo.sentenceRects[0].left, 16 | anchorDrawInfo.sentenceRects[0].bottom + config.paddingY.value, 17 | ), 18 | new Point( 19 | anchorDrawInfo.sentenceRects[0].left + config.fixedUnderlineLength.value, 20 | anchorDrawInfo.sentenceRects[0].bottom + config.paddingY.value, 21 | ), 22 | ); 23 | renderer.drawPolygon(polygonVertices, drawOption); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/draw/OutlineDrawer.ts: -------------------------------------------------------------------------------- 1 | import { AnchorDrawInfo } from '../AnchorDrawInfo'; 2 | import { ConfigManager } from '../config/ConfigManager'; 3 | import { Rect } from '../Rect'; 4 | import { Renderer } from '../Renderer'; 5 | import { Drawer } from './Drawer'; 6 | import { DrawOption } from './DrawOption'; 7 | 8 | export class OutlineDrawer implements Drawer { 9 | draw(renderer: Renderer, anchorDrawInfo: AnchorDrawInfo, drawOption: DrawOption): void { 10 | const config = ConfigManager.getInstance(); 11 | 12 | const marginAppliedRects: Rect[] = []; 13 | for (const rect of anchorDrawInfo.sentenceRects) { 14 | const marginAppliedRect = Rect.from(rect); 15 | marginAppliedRect.x -= config.paddingX.value; 16 | marginAppliedRect.y -= config.paddingY.value; 17 | marginAppliedRect.width += config.paddingX.value * 2; 18 | marginAppliedRect.height += config.paddingY.value * 2; 19 | marginAppliedRects.push(marginAppliedRect); 20 | } 21 | 22 | for (const marginAppliedRect of marginAppliedRects) { 23 | renderer.drawRect(marginAppliedRect, drawOption); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/draw/HighlighterDrawer.ts: -------------------------------------------------------------------------------- 1 | import { AnchorDrawInfo } from '../AnchorDrawInfo'; 2 | import { ConfigManager } from '../config/ConfigManager'; 3 | import { Rect } from '../Rect'; 4 | import { Renderer } from '../Renderer'; 5 | import { Drawer } from './Drawer'; 6 | import { DrawOption } from './DrawOption'; 7 | 8 | export class HighlighterDrawer implements Drawer { 9 | draw(renderer: Renderer, anchorDrawInfo: AnchorDrawInfo, drawOption: DrawOption): void { 10 | const config = ConfigManager.getInstance(); 11 | 12 | const marginAppliedRects: Rect[] = []; 13 | for (const rect of anchorDrawInfo.sentenceRects) { 14 | const marginAppliedRect = Rect.from(rect); 15 | marginAppliedRect.x -= config.paddingX.value; 16 | marginAppliedRect.y -= config.paddingY.value; 17 | marginAppliedRect.width += config.paddingX.value * 2; 18 | marginAppliedRect.height += config.paddingY.value * 2; 19 | marginAppliedRects.push(marginAppliedRect); 20 | } 21 | 22 | for (const marginAppliedRect of marginAppliedRects) { 23 | renderer.fillRect(marginAppliedRect, drawOption); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/draw/SpotlightDrawer.ts: -------------------------------------------------------------------------------- 1 | import { AnchorDrawInfo } from '../AnchorDrawInfo'; 2 | import { ConfigManager } from '../config/ConfigManager'; 3 | import { Rect } from '../Rect'; 4 | import { Renderer } from '../Renderer'; 5 | import { Drawer } from './Drawer'; 6 | import { DrawOption } from './DrawOption'; 7 | 8 | export class SpotlightDrawer implements Drawer { 9 | draw(renderer: Renderer, anchorDrawInfo: AnchorDrawInfo, drawOption: DrawOption): void { 10 | const config = ConfigManager.getInstance(); 11 | 12 | const marginAppliedRects: Rect[] = []; 13 | for (const rect of anchorDrawInfo.sentenceRects) { 14 | const marginAppliedRect = Rect.from(rect); 15 | marginAppliedRect.x -= config.paddingX.value; 16 | marginAppliedRect.y -= config.paddingY.value; 17 | marginAppliedRect.width += config.paddingX.value * 2; 18 | marginAppliedRect.height += config.paddingY.value * 2; 19 | marginAppliedRects.push(marginAppliedRect); 20 | } 21 | 22 | drawOption.rgbColor = 'rgb(0, 0, 0)'; 23 | drawOption.opacityRatio = 50; 24 | 25 | renderer.fillOutsideOfRects(marginAppliedRects, drawOption); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "focus-anchor", 3 | "version": "1.6.0", 4 | "main": "index.js", 5 | "scripts": { 6 | "test": "echo \"Error: no test specified\" && exit 1", 7 | "build": "webpack --mode production", 8 | "dev": "webpack --mode development --watch", 9 | "clean": "rimraf dist" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "ISC", 14 | "description": "", 15 | "devDependencies": { 16 | "@types/chrome": "^0.0.315", 17 | "@types/jsdom": "^21.1.7", 18 | "@types/node": "^22.14.1", 19 | "canvas": "^3.1.0", 20 | "copy-webpack-plugin": "^13.0.0", 21 | "css-loader": "^7.1.2", 22 | "dotenv-webpack": "^8.1.0", 23 | "global-jsdom": "^26.0.0", 24 | "jsdom": "^26.1.0", 25 | "mini-css-extract-plugin": "^2.9.2", 26 | "prettier": "^3.6.2", 27 | "sass": "^1.86.3", 28 | "sass-loader": "^16.0.5", 29 | "ts-loader": "^9.5.2", 30 | "ts-node": "^10.9.2", 31 | "typescript": "^5.8.3", 32 | "webpack": "^5.99.6", 33 | "webpack-cli": "^6.0.1", 34 | "webpack-dev-server": "^5.2.1" 35 | }, 36 | "overrides": { 37 | "tar-fs": "^2.1.3" 38 | }, 39 | "dependencies": { 40 | "colord": "^2.9.3", 41 | "idlejs": "^3.0.0" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const CopyPlugin = require('copy-webpack-plugin'); 3 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 4 | const Dotenv = require('dotenv-webpack'); 5 | 6 | module.exports = { 7 | entry: { 8 | main: path.resolve(__dirname, 'src', 'main.ts'), 9 | content: path.resolve(__dirname, 'src', 'content.ts'), 10 | background: path.resolve(__dirname, 'src', 'background.ts'), 11 | }, 12 | output: { 13 | path: path.resolve(__dirname, 'dist'), 14 | filename: '[name].js', 15 | }, 16 | resolve: { extensions: ['.ts', '.js'] }, 17 | module: { 18 | rules: [ 19 | { test: /\.tsx?$/, loader: 'ts-loader', exclude: /node_modules/ }, 20 | { 21 | test: /\.(sa|sc|c)ss$/, 22 | use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader'], 23 | }, 24 | { 25 | test: /\.(png|jpe?g|gif|svg|woff2?|eot|ttf)$/i, 26 | type: 'asset', 27 | }, 28 | ], 29 | }, 30 | plugins: [ 31 | new CopyPlugin({ 32 | patterns: [ 33 | { from: 'public', to: '.' }, 34 | { from: 'manifest.json', to: 'manifest.json' }, 35 | ], 36 | }), 37 | new MiniCssExtractPlugin({ filename: '[name].css' }), 38 | new Dotenv(), 39 | ], 40 | 41 | devtool: 'source-map', 42 | }; 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Introduction 2 | 3 | A tool to help you anchor focus, one sentence at a time. 4 | 5 | Designed with flexibility in mind, this Chrome extension is expected to work reliably on most websites, whether they're rendered via CSR or SSR. 6 | 7 | ## Features 8 | 9 | ### Sentence-level focus 10 | 11 | Highlight content at the sentence level using either mouse clicks or keyboard input. 12 | 13 | ![sentence-level-focus](https://github.com/user-attachments/assets/6e6b272f-e04a-411f-9968-49c248fd62a9) 14 | 15 | ### Smart scrolling 16 | 17 | Automatically scrolls to the focused sentence. Place it exactly where you want on the screen for optimal readability. 18 | 19 | **Auto Scroll** 20 | 21 | ![smart-scroll-1](https://github.com/user-attachments/assets/4c101470-a1f1-434c-a431-b4af570b0904) 22 | 23 | **Scroll Alignment** 24 | 25 | ![smart-scroll-2](https://github.com/user-attachments/assets/4fd31fe9-debb-4b39-8f8d-4bcd3ae3c6fc) 26 | 27 | Scroll Alignment also works well in nested scroll containers. 28 | 29 | ![focus-anchor-scroll-bias](https://github.com/user-attachments/assets/fb2fd726-2989-4d84-8b64-7aecfe8514ba) 30 | 31 | ### Multiple focus styles 32 | 33 | Choose from underline, outline, or spotlight effects. Fine-tune the appearance by adjusting line thickness, spacing, and more to match your preference. 34 | 35 | ![focus-style](https://github.com/user-attachments/assets/eb4aed19-f4c4-4d14-9c05-68d452ebc911) 36 | -------------------------------------------------------------------------------- /src/draw/BracketDrawer.ts: -------------------------------------------------------------------------------- 1 | import { AnchorDrawInfo } from '../AnchorDrawInfo'; 2 | import { ConfigManager } from '../config/ConfigManager'; 3 | import { Point } from '../Point'; 4 | import { Renderer } from '../Renderer'; 5 | import { Drawer } from './Drawer'; 6 | import { DrawOption } from './DrawOption'; 7 | 8 | export class BracketDrawer implements Drawer { 9 | draw(renderer: Renderer, anchorDrawInfo: AnchorDrawInfo, drawOption: DrawOption): void { 10 | const config = ConfigManager.getInstance(); 11 | 12 | const firstRect = anchorDrawInfo.sentenceRects[0]; 13 | const lastRect = anchorDrawInfo.sentenceRects[anchorDrawInfo.sentenceRects.length - 1]; 14 | 15 | const bracketWidth = firstRect.height / 4; 16 | 17 | const leftBracketVertices: Point[] = []; 18 | leftBracketVertices.push( 19 | new Point(firstRect.left - config.paddingX.value + bracketWidth, firstRect.top), 20 | new Point(firstRect.left - config.paddingX.value, firstRect.top), 21 | new Point(firstRect.left - config.paddingX.value, firstRect.bottom), 22 | new Point(firstRect.left - config.paddingX.value + bracketWidth, firstRect.bottom), 23 | ); 24 | 25 | const rightBracketVertices: Point[] = []; 26 | rightBracketVertices.push( 27 | new Point(lastRect.right + config.paddingX.value - bracketWidth, lastRect.top), 28 | new Point(lastRect.right + config.paddingX.value, lastRect.top), 29 | new Point(lastRect.right + config.paddingX.value, lastRect.bottom), 30 | new Point(lastRect.right + config.paddingX.value - bracketWidth, lastRect.bottom), 31 | ); 32 | 33 | renderer.drawLines(leftBracketVertices, drawOption); 34 | renderer.drawLines(rightBracketVertices, drawOption); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/debug.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'fs'; 2 | import createGlobalJsdom from 'global-jsdom'; 3 | import { Canvas, Image, ImageData } from 'canvas'; 4 | 5 | // 0) Canvas API를 globalThis에 등록 6 | Object.assign(globalThis, { Canvas, Image, ImageData }); 7 | 8 | // 1) sample.html 읽어서 jsdom 전역 설치 9 | const html = readFileSync('sample.html', 'utf8'); 10 | const cleanup = createGlobalJsdom(html, { 11 | url: 'https://example.com', 12 | pretendToBeVisual: true, 13 | }); 14 | 15 | // 2) 최소한의 chrome 확장 API 스텁 16 | (globalThis as any).chrome = { 17 | runtime: { 18 | sendMessage: () => {}, 19 | onMessage: { addListener: () => {} }, 20 | }, 21 | storage: { 22 | local: { 23 | get: (_keys: string | string[] | { [key: string]: any }, callback: (items: any) => void) => { 24 | const result = { focusActive: true }; 25 | if (typeof callback === 'function') { 26 | callback(result); 27 | } else { 28 | return Promise.resolve(result); 29 | } 30 | }, 31 | set: (_items: any, callback?: () => void) => { 32 | callback?.(); 33 | }, 34 | remove: (_keys: string | string[], callback?: () => void) => { 35 | callback?.(); 36 | }, 37 | clear: (callback?: () => void) => { 38 | callback?.(); 39 | }, 40 | }, 41 | onChanged: { 42 | addListener: (_listner: Function) => {}, 43 | }, 44 | }, 45 | }; 46 | 47 | (async () => { 48 | try { 49 | // 3) content.ts 를 불러오면, 그 안에서 load 이벤트 핸들러 등록 50 | await import('./content'); 51 | console.log('[debug] content.ts imported'); 52 | 53 | // 4) 여기서 load 이벤트를 강제로 날리기 54 | window.document.dispatchEvent(new window.Event('DOMContentLoaded')); 55 | console.log('[debug] window.load dispatched'); 56 | } catch (err) { 57 | console.error(err); 58 | } finally { 59 | // 5) N초 뒤에 cleanup() 60 | setTimeout(() => { 61 | cleanup(); 62 | console.log('[debug] jsdom cleaned up'); 63 | }, 10_000); 64 | } 65 | })(); 66 | -------------------------------------------------------------------------------- /public/release-note.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |

Version 1.6.0 (2025-11-05)

5 |

6 | 7 | [GitHub Release Note] 8 | 9 |

10 |
    11 | New Feature: Focus on Cursor Stay 12 |
    13 | When the mouse cursor hovers over a sentence for a certain duration, that sentence is automatically focused. 14 |

    15 | image 16 |

    17 |

    18 | image 19 |

    20 | 21 |
22 | 23 |
24 | 25 |

Version 1.5.1 (2025-10-21)

26 |

27 | 28 | [GitHub Release Note] 29 | 30 |

31 |
    32 |
  • Added release note feature.
  • 33 |
34 | 35 |
36 | 37 |

Version 1.5.0 (2025-09-07)

38 |

39 | 40 | [GitHub Release Note] 41 | 42 |

43 |
    44 |
  • 45 | Added opacity configuration option. 46 |

    47 | image 48 |

    49 |

    50 | image 51 |

    52 |
  • 53 |
  • Improved numbered list segmentation.
  • 54 |
  • 55 | Fixed rounded corners not applied in merged rectangle strategy. 56 |

    57 | Before 58 | image 59 |

    60 |

    61 | After 62 | image 63 |

    64 |
  • 65 |
  • Fixed invisible highlight issue in First Character Highlighter.
  • 66 |
  • Fixed missing padding in merged rectangles.
  • 67 |
  • Fixed backward compatibility issue with settings.
  • 68 |
  • Fixed sentence splitting failure caused by whitespace removal.
  • 69 |
70 |
71 | -------------------------------------------------------------------------------- /public/popup.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #f0f2f5; /* Softer, modern background */ 3 | font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; /* Modern font stack */ 4 | } 5 | #control-container { 6 | display: flex; 7 | gap: 8px; /* Slightly increased gap */ 8 | margin-bottom: 20px; /* More space below control buttons */ 9 | } 10 | 11 | #control-container button { 12 | display: flex; 13 | align-items: center; 14 | height: 36px; /* Slightly taller buttons */ 15 | width: fit-content; 16 | padding: 0 15px; /* Add horizontal padding */ 17 | border-radius: 6px; /* Slightly rounded buttons */ 18 | } 19 | 20 | #control-container img { 21 | height: 24px; /* Reduced size for the reload icon */ 22 | width: 24px; /* Ensure it's square */ 23 | } 24 | 25 | #config-container { 26 | display: flex; 27 | flex-direction: column; 28 | gap: 12px; /* Increased gap between config items */ 29 | background-color: #ffffff; /* White background */ 30 | } 31 | 32 | .config-item { 33 | display: flex; 34 | justify-content: space-between; 35 | align-items: center; 36 | padding: 15px 10px; 37 | background-color: #e7e7e7; 38 | border-radius: 5px 20px 5px 20px; 39 | } 40 | 41 | .form-label { 42 | font-weight: 500; /* Medium bold */ 43 | color: #495057; /* Slightly softer dark text */ 44 | font-size: 0.95em; /* Slightly smaller label font */ 45 | } 46 | 47 | summary { 48 | font-size: 1.4em; /* Larger summary font */ 49 | font-weight: 600; /* Bolder summary */ 50 | margin-bottom: 15px; /* More space below summary */ 51 | color: #000000; /* Primary blue for summary */ 52 | cursor: pointer; /* Indicate it's clickable */ 53 | } 54 | 55 | .form-control, 56 | .form-select { 57 | border-radius: 5px; /* Slightly rounded input fields */ 58 | border-color: #ced4da; /* Standard border color */ 59 | } 60 | 61 | .form-control:focus, 62 | .form-select:focus { 63 | box-shadow: 0 0 0 0.25rem rgba(0, 123, 255, 0.25); /* Bootstrap-like focus glow */ 64 | border-color: #80bdff; /* Blue border on focus */ 65 | } 66 | 67 | #reset { 68 | margin-top: 15px; /* More space above reset button */ 69 | width: 50%; 70 | border-radius: 6px; /* Rounded button */ 71 | } 72 | 73 | #state { 74 | margin-top: 10px; 75 | text-align: center; 76 | font-weight: 500; 77 | } 78 | 79 | #release-note-button { 80 | position: relative; 81 | } 82 | 83 | .notification-badge { 84 | position: absolute; 85 | top: 2px; 86 | right: 2px; 87 | width: 8px; 88 | height: 8px; 89 | background-color: red; 90 | border-radius: 50%; 91 | border: 1px solid white; 92 | } 93 | 94 | #release-note-container { 95 | padding: 1.5em; 96 | width: 410px; 97 | } 98 | -------------------------------------------------------------------------------- /src/draw/MergedOutlineDrawer.ts: -------------------------------------------------------------------------------- 1 | import { AnchorDrawInfo } from '../AnchorDrawInfo'; 2 | import { ConfigManager } from '../config/ConfigManager'; 3 | import { Point } from '../Point'; 4 | import { Renderer } from '../Renderer'; 5 | import { Drawer } from './Drawer'; 6 | import { DrawOption } from './DrawOption'; 7 | 8 | export class MergedOutlineDrawer implements Drawer { 9 | draw(renderer: Renderer, anchorDrawInfo: AnchorDrawInfo, drawOption: DrawOption): void { 10 | const config = ConfigManager.getInstance(); 11 | 12 | for (const rect of anchorDrawInfo.sentenceRects) { 13 | rect.x -= config.paddingX.value; 14 | rect.y -= config.paddingY.value; 15 | rect.width += config.paddingX.value * 2; 16 | rect.height += config.paddingY.value * 2; 17 | } 18 | 19 | // 폴리곤 정점 구성 20 | const leftVertices: Point[] = []; 21 | const rightVertices: Point[] = []; 22 | 23 | const firstRect = anchorDrawInfo.sentenceRects[0]; 24 | leftVertices.push(new Point(firstRect.left, firstRect.top)); 25 | rightVertices.push(new Point(firstRect.right, firstRect.top)); 26 | 27 | for (let i = 0; i < anchorDrawInfo.sentenceRects.length; i++) { 28 | const rect = anchorDrawInfo.sentenceRects[i]; 29 | 30 | leftVertices.push(new Point(rect.left, rect.bottom)); 31 | rightVertices.push(new Point(rect.right, rect.bottom)); 32 | 33 | if (i + 1 == anchorDrawInfo.sentenceRects.length) { 34 | continue; 35 | } 36 | const nextRect = anchorDrawInfo.sentenceRects[i + 1]; 37 | 38 | // 충돌안하면 사각형 분리. 39 | if (rect.right < nextRect.left || rect.left > nextRect.right) { 40 | const polygonVertices: Point[] = []; 41 | 42 | for (let i = 0; i < rightVertices.length; i++) { 43 | polygonVertices.push(rightVertices[i]); 44 | } 45 | for (let i = leftVertices.length - 1; i >= 0; i--) { 46 | polygonVertices.push(leftVertices[i]); 47 | } 48 | 49 | renderer.drawPolygon(polygonVertices, drawOption); 50 | 51 | leftVertices.splice(0, leftVertices.length); 52 | rightVertices.splice(0, rightVertices.length); 53 | 54 | leftVertices.push(new Point(nextRect.left, nextRect.top)); 55 | rightVertices.push(new Point(nextRect.right, nextRect.top)); 56 | } 57 | // 충돌할경우, 직각을 유지하기 위해 중간 Y값을 가진 정점 추가. 58 | else { 59 | let leftY = rect.left <= nextRect.left ? rect.bottom : nextRect.top; 60 | leftVertices.pop(); 61 | leftVertices.push(new Point(rect.left, leftY)); 62 | if (rect.left != nextRect.left) { 63 | leftVertices.push(new Point(nextRect.left, leftY)); 64 | } 65 | 66 | let rightY = rect.right >= nextRect.right ? rect.bottom : nextRect.top; 67 | rightVertices.pop(); 68 | rightVertices.push(new Point(rect.right, rightY)); 69 | if (rect.right != nextRect.right) { 70 | rightVertices.push(new Point(nextRect.right, rightY)); 71 | } 72 | } 73 | } 74 | 75 | const polygonVertices: Point[] = []; 76 | for (let i = 0; i < rightVertices.length; i++) { 77 | polygonVertices.push(rightVertices[i]); 78 | } 79 | for (let i = leftVertices.length - 1; i >= 0; i--) { 80 | polygonVertices.push(leftVertices[i]); 81 | } 82 | renderer.drawPolygon(polygonVertices, drawOption); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/config/Config.ts: -------------------------------------------------------------------------------- 1 | import { DrawStrategy } from '../draw/DrawStrategy.enum'; 2 | import { ToastOption } from '../Renderer'; 3 | import { ColorConfigItem } from './ColorConfigItem'; 4 | import { DropdownConfigItem } from './DropdownConfigItem'; 5 | import { NumberConfigItem } from './NumberConfigItem'; 6 | 7 | export class Config { 8 | static get default(): Config { 9 | return new Config(); 10 | } 11 | 12 | static from(object: any): Config { 13 | const config = Config.default; 14 | Config.assignProperties(config, object); 15 | return config; 16 | } 17 | 18 | static assignProperties(config: Config, object: any): void { 19 | for (const key of Object.keys(object) as (keyof Config)[]) { 20 | if (config[key] instanceof NumberConfigItem) { 21 | /* 22 | 1.4.0에서 Config 코드 리팩토링된 버전으로 업데이트할 때 기존 설정값을 불러올 수 있도록 로직 추가. 23 | 확장프로그램을 비활성화 해두거나 혹은 설정창을 계속 열지 않는다면 main.ts의 저장 로직으로 가지 못할 수 있으므로 최소 세 번의 업데이트 후에 삭제해야할 듯 함. 24 | */ 25 | if (object[key].value === undefined && object[key] != undefined) { 26 | const oldNumberValue = object[key]; 27 | delete object[key]; 28 | object[key] = new NumberConfigItem( 29 | oldNumberValue, 30 | config[key].minValue, 31 | config[key].maxValue, 32 | ); 33 | } 34 | 35 | config[key].value = object[key].value; 36 | continue; 37 | } 38 | if (config[key] instanceof DropdownConfigItem) { 39 | config[key].selected = object[key].selected; 40 | continue; 41 | } 42 | if (config[key] instanceof ColorConfigItem) { 43 | config[key].selected = object[key].selected; 44 | continue; 45 | } 46 | 47 | (config as any)[key] = object[key]; 48 | } 49 | } 50 | 51 | paddingX = new NumberConfigItem(parseInt(process.env.DEFAULT_PADDING_X ?? '1'), null, null); 52 | paddingY = new NumberConfigItem(parseInt(process.env.DEFAULT_PADDING_X ?? '2'), null, null); 53 | 54 | drawStrategy = new DropdownConfigItem( 55 | DrawStrategy[(process.env.DEFAULT_DRAW_STRATEGY as keyof typeof DrawStrategy) ?? 'Underline'], 56 | [ 57 | DrawStrategy.Underline, 58 | DrawStrategy.FixedUnderline, 59 | DrawStrategy.Outline, 60 | DrawStrategy.MergedOutline, 61 | DrawStrategy.Spotlight, 62 | DrawStrategy.FirstCharOutline, 63 | DrawStrategy.Highlighter, 64 | DrawStrategy.FirstCharHighlighter, 65 | DrawStrategy.Bracket, 66 | ], 67 | ); 68 | 69 | drawColor = new ColorConfigItem(process.env.DEFAULT_DRAW_COLOR ?? '#FF0000'); 70 | 71 | opacity = new NumberConfigItem(parseInt(process.env.DEFAULT_OPACITY ?? '100'), 0, 100); 72 | 73 | lineWidth = new NumberConfigItem(parseInt(process.env.DEFAULT_LINE_WIDTH ?? '3'), 1, null); 74 | 75 | borderRadius = new NumberConfigItem(parseInt(process.env.DEFAULT_BORDER_RADIUS ?? '0'), 0, 100); 76 | 77 | fixedUnderlineLength = new NumberConfigItem( 78 | parseInt(process.env.DEFAULT_FIXED_UNDERLINE_LENGTH ?? '20'), 79 | null, 80 | null, 81 | ); 82 | 83 | autoScroll = new DropdownConfigItem(process.env.DEFAULT_AUTO_SCROLL ?? 'true', [ 84 | 'true', 85 | 'false', 86 | ]); 87 | scrollBehavior = new DropdownConfigItem( 88 | (process.env.DEFAULT_SCROLL_BEHAVIOR as ScrollBehavior) ?? 'smooth', 89 | ['smooth', 'instant'], 90 | ); 91 | 92 | strictClickDetection = new DropdownConfigItem( 93 | process.env.DEFAULT_STRICT_CLICK_DETECTION ?? 'true', 94 | ['true', 'false'], 95 | ); 96 | 97 | focusYBias = new NumberConfigItem(parseInt(process.env.DEFAULT_FOCUS_Y_BIAS ?? '30'), 0, 100); 98 | 99 | toggleHotkey: string = process.env.DEFAULT_TOGGLE_HOTKEY ?? 'Control+Shift+F'; 100 | movePrevHotkey: string = process.env.DEFAULT_MOVE_PREV_HOTKEY ?? 'ArrowLeft'; 101 | moveNextHotkey: string = process.env.DEFAULT_MOVE_NEXT_HOTKEY ?? 'ArrowRight'; 102 | 103 | toastOption = new DropdownConfigItem( 104 | (process.env.DEFAULT_TOAST_OPTION as ToastOption) ?? ToastOption.BOTTOM, 105 | [ToastOption.TOP, ToastOption.MIDDLE, ToastOption.BOTTOM, ToastOption.DISABLED], 106 | ); 107 | 108 | useFocusOnCursorStay = new DropdownConfigItem( 109 | process.env.DEFAULT_USE_FOCUS_ON_CURSOR_STAY ?? 'false', 110 | ['false', 'true'], 111 | ); 112 | focusOnCursorStayTimeMillis = new NumberConfigItem( 113 | parseInt(process.env.DEFAULT_FOCUS_ON_CURSOR_STAY_TIME_MILLIS ?? '700'), 114 | 100, 115 | 10000, 116 | ); 117 | } 118 | -------------------------------------------------------------------------------- /public/test-scroll.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
13 | 1111
14 | 1111
15 | 1111
16 | 1111
17 | 1111
18 | 1111
19 | 1111
20 | 1111
21 | 1111
22 | 1111
23 | 1111
24 | 1111
25 | 1111
26 | 1111
27 | 1111
28 | 1111
29 | 1111
30 | 1111
31 | 1111
32 | 1111
33 | 1111
34 | 1111 35 |
45 | 22222222
46 | 22222222
47 | 22222222
48 | 22222222
49 | 22222222
50 | 22222222
51 | 22222222
52 | 22222222
53 | 22222222
54 | 22222222
55 | 22222222
56 | 22222222
57 | 22222222
58 | 22222222
59 | 22222222
60 | 22222222
61 | 22222222
62 | 22222222
63 | 22222222
64 | 22222222
65 | 22222222
66 | 22222222
67 | 22222222
68 | 22222222
69 | 22222222
70 | 22222222
71 | 22222222
72 | 22222222
73 | 22222222
74 | 22222222
75 | 22222222
76 | 22222222
77 | 22222222
78 | 22222222
79 | 22222222
80 | 22222222
81 | 22222222
82 | 22222222
83 |
93 | 3333333333333333
94 | 3333333333333333
95 | 3333333333333333
96 | 3333333333333333
97 | 3333333333333333
98 | 3333333333333333
99 | 3333333333333333
100 | 3333333333333333
101 | 3333333333333333
102 | 3333333333333333
103 | 3333333333333333
104 | 3333333333333333
105 | 3333333333333333
106 | 3333333333333333
107 | 3333333333333333
108 | 3333333333333333
109 | 3333333333333333
110 | 3333333333333333
111 | 3333333333333333
112 | 3333333333333333
113 | 3333333333333333
114 | 3333333333333333
115 | 3333333333333333
116 | 3333333333333333
117 | 3333333333333333
118 |
119 | 22222222
120 | 22222222
121 | 22222222
122 | 22222222
123 | 22222222
124 | 22222222
125 | 22222222
126 | 22222222
127 | 22222222
128 | 22222222
129 | 22222222
130 | 22222222
131 | 22222222
132 | 22222222
133 | 22222222
134 | 22222222
135 | 22222222
136 | 22222222
137 | 22222222
138 | 22222222
139 | 22222222
140 | 22222222
141 | 22222222
142 | 22222222
143 | 22222222
144 | 22222222
145 | 22222222
146 | 22222222
147 | 22222222
148 | 22222222
149 | 22222222
150 | 22222222
151 | 22222222
152 | 22222222
153 | 22222222
154 | 22222222
155 | 22222222
156 | 22222222
157 |
158 | 1111
159 | 1111
160 | 1111
161 | 1111
162 | 1111
163 | 1111
164 | 1111
165 | 1111
166 | 1111
167 | 1111
168 | 1111
169 | 1111
170 | 1111
171 | 1111
172 | 1111
173 | 1111
174 | 1111
175 | 1111
176 | 1111
177 | 1111
178 | 1111
179 | 1111 180 |
181 | 182 | 183 | -------------------------------------------------------------------------------- /src/Renderer.ts: -------------------------------------------------------------------------------- 1 | import { DrawOption } from './draw/DrawOption'; 2 | import { Point } from './Point'; 3 | import { Rect } from './Rect'; 4 | import { Utils } from './Utils'; 5 | 6 | export enum ToastOption { 7 | TOP = 'top', 8 | MIDDLE = 'middle', 9 | BOTTOM = 'bottom', 10 | DISABLED = 'disabled', 11 | } 12 | 13 | export class Renderer { 14 | canvas: HTMLCanvasElement; 15 | ctx: CanvasRenderingContext2D; 16 | 17 | constructor() { 18 | const canvasId = 'focus-anchor-overlay-canvas'; 19 | 20 | this.canvas = document.getElementById(canvasId) as HTMLCanvasElement; 21 | if (!this.canvas) { 22 | this.canvas = document.createElement('canvas'); 23 | this.canvas.id = canvasId; 24 | 25 | Object.assign(this.canvas.style, { 26 | position: 'fixed', 27 | top: '0', 28 | left: '0', 29 | pointerEvents: 'none', 30 | zIndex: '9999', 31 | }); 32 | 33 | document.body.appendChild(this.canvas); 34 | } 35 | 36 | this.ctx = this.canvas.getContext('2d') as CanvasRenderingContext2D; 37 | } 38 | 39 | updateCanvasZIndex(zIndex: number) { 40 | this.canvas.style.zIndex = zIndex.toString(); 41 | } 42 | 43 | updateCanvasSize() { 44 | this.canvas.width = document.documentElement.clientWidth; 45 | this.canvas.height = document.documentElement.clientHeight; 46 | } 47 | 48 | clearCanvas(): void { 49 | this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); 50 | } 51 | 52 | drawLine(from: Point, to: Point, drawOption: DrawOption): void { 53 | this.ctx.strokeStyle = drawOption.rgba; 54 | this.ctx.lineWidth = drawOption.lineWidth; 55 | 56 | this.ctx.beginPath(); 57 | this.ctx.moveTo(from.x, from.y); 58 | this.ctx.lineTo(to.x, to.y); 59 | this.ctx.stroke(); 60 | } 61 | 62 | drawLines(vertices: Point[], drawOption: DrawOption): void { 63 | if (vertices.length == 0) return; 64 | 65 | this.ctx.strokeStyle = drawOption.rgba; 66 | this.ctx.lineWidth = drawOption.lineWidth; 67 | 68 | this.ctx.beginPath(); 69 | this.ctx.moveTo(vertices[0].x, vertices[0].y); 70 | for (let i = 1; i < vertices.length; i++) { 71 | this.ctx.lineTo(vertices[i].x, vertices[i].y); 72 | } 73 | this.ctx.stroke(); 74 | } 75 | 76 | drawRect(rect: Rect, drawOption: DrawOption): void { 77 | const radius = (Math.min(rect.width, rect.height) * (drawOption.radiusRatio / 100)) / 2; 78 | 79 | this.ctx.strokeStyle = drawOption.rgba; 80 | this.ctx.lineWidth = drawOption.lineWidth; 81 | this.ctx.beginPath(); 82 | this.ctx.roundRect(rect.x, rect.y, rect.width, rect.height, radius); 83 | this.ctx.stroke(); 84 | } 85 | 86 | fillRect(rect: Rect, drawOption: DrawOption): void { 87 | const radius = (Math.min(rect.width, rect.height) * (drawOption.radiusRatio / 100)) / 2; 88 | 89 | this.ctx.fillStyle = drawOption.rgba; 90 | this.ctx.beginPath(); 91 | this.ctx.roundRect(rect.x, rect.y, rect.width, rect.height, radius); 92 | this.ctx.fill(); 93 | } 94 | 95 | fillOutsideOfRects(rects: Rect[], drawOption: DrawOption): void { 96 | this.ctx.fillStyle = drawOption.rgba; 97 | this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); 98 | 99 | this.ctx.save(); 100 | this.ctx.globalCompositeOperation = 'destination-out'; 101 | this.ctx.fillStyle = 'rgba(0, 0, 0, 1)'; 102 | 103 | for (const rect of rects) { 104 | const radius = (Math.min(rect.width, rect.height) * (drawOption.radiusRatio / 100)) / 2; 105 | 106 | this.ctx.beginPath(); 107 | this.ctx.roundRect(rect.x, rect.y, rect.width, rect.height, radius); 108 | this.ctx.fill(); 109 | } 110 | 111 | this.ctx.restore(); 112 | } 113 | 114 | clearRect(rect: Rect) { 115 | this.ctx.clearRect(rect.x, rect.y, rect.width, rect.height); 116 | } 117 | 118 | drawPolygon(vertices: Point[], drawOption: DrawOption): void { 119 | if (vertices.length == 0) return; 120 | 121 | this.ctx.strokeStyle = drawOption.rgba; 122 | this.ctx.lineWidth = drawOption.lineWidth; 123 | 124 | this.ctx.beginPath(); 125 | for (let i = 0; i < vertices.length; i++) { 126 | const prv = vertices[(i - 1 + vertices.length) % vertices.length]; 127 | const cur = vertices[i]; 128 | const nxt = vertices[(i + 1) % vertices.length]; 129 | const nnxt = vertices[(i + 2) % vertices.length]; 130 | 131 | /* 132 | u->v = {v.x-u.x, v.y-u.y} 133 | normalized(u->v) = {(v.x-u.x) / len(u->v), (v.y-u.y) / len(u->v)} 134 | */ 135 | const lenFromPrvToCur = Utils.getVectorLength(prv, cur); 136 | const lenFromCurToNxt = Utils.getVectorLength(cur, nxt); 137 | const lenFromNxtToNnxt = Utils.getVectorLength(nxt, nnxt); 138 | 139 | const prvRadius = 140 | (Math.min(lenFromPrvToCur, lenFromCurToNxt) / 2) * (drawOption.radiusRatio / 100); 141 | const radius = 142 | (Math.min(lenFromCurToNxt, lenFromNxtToNnxt) / 2) * (drawOption.radiusRatio / 100); 143 | const offset = new Point( 144 | ((nxt.x - cur.x) / lenFromCurToNxt) * prvRadius, 145 | ((nxt.y - cur.y) / lenFromCurToNxt) * prvRadius, 146 | ); 147 | const offseted = Point.add(cur, offset); 148 | 149 | if (prv.x == nxt.x || prv.y == nxt.y) { 150 | this.ctx.moveTo(cur.x, cur.y); 151 | this.ctx.lineTo(offseted.x, offseted.y); 152 | } 153 | 154 | this.ctx.moveTo(offseted.x, offseted.y); 155 | 156 | this.ctx.arcTo(nxt.x, nxt.y, nnxt.x, nnxt.y, radius); 157 | } 158 | this.ctx.stroke(); 159 | } 160 | 161 | fillScreen(drawOption: DrawOption): void { 162 | this.fillRect(new Rect(0, 0, this.canvas.width, this.canvas.height), drawOption); 163 | } 164 | 165 | showToast( 166 | message: string, 167 | duration: number = 3000, 168 | toastOption: ToastOption = ToastOption.BOTTOM, 169 | color: string = 'rgba(0, 0, 0, 0.7)', 170 | ): void { 171 | if (toastOption === ToastOption.DISABLED) { 172 | return; 173 | } 174 | 175 | const toast = document.createElement('div'); 176 | toast.textContent = message; 177 | toast.style.position = 'fixed'; 178 | toast.style.left = '50%'; 179 | toast.style.transform = 'translateX(-50%)'; 180 | toast.style.backgroundColor = color; 181 | toast.style.color = 'white'; 182 | toast.style.padding = '10px 20px'; 183 | toast.style.borderRadius = '5px'; 184 | toast.style.zIndex = '999999'; 185 | toast.style.opacity = '0'; 186 | toast.style.transition = 'opacity 0.5s'; 187 | 188 | switch (toastOption) { 189 | case ToastOption.TOP: 190 | toast.style.top = '10%'; 191 | break; 192 | case ToastOption.MIDDLE: 193 | toast.style.top = '50%'; 194 | toast.style.transform = 'translate(-50%, -50%)'; 195 | break; 196 | case ToastOption.BOTTOM: 197 | toast.style.bottom = '10%'; 198 | break; 199 | } 200 | 201 | document.body.appendChild(toast); 202 | 203 | setTimeout(() => { 204 | toast.style.opacity = '1'; 205 | }, 100); 206 | 207 | setTimeout(() => { 208 | toast.style.opacity = '0'; 209 | setTimeout(() => { 210 | document.body.removeChild(toast); 211 | }, 500); 212 | }, duration); 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /public/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Focus Anchor Configs 8 | 14 | 15 | 16 | 22 | 23 |
24 | 25 |
26 | 29 | 32 | 36 |
37 |
38 | 39 | 40 |
41 | Config 42 |
43 |
44 | 45 | 52 |
53 |
54 | 55 | 62 |
63 | 64 |
65 | 66 | 77 |
78 | 79 |
80 | 81 | 88 |
89 | 90 |
91 | 92 | 102 |
103 | 104 |
105 | 106 | 113 |
114 | 115 |
116 | 117 | 124 |
125 | 126 |
127 | 128 | 135 |
136 | 137 |
138 | 139 | 143 |
144 | 145 |
146 | 147 | 151 |
152 | 153 |
154 | 155 | 159 |
160 | 161 |
162 | 163 | 170 |
171 | 172 |
173 | 174 |
175 | 183 | 184 |
185 |
186 | 187 |
188 | 189 |
190 | 198 | 201 |
202 |
203 | 204 |
205 | 206 |
207 | 215 | 218 |
219 |
220 | 221 |
222 | 223 | 229 |
230 | 231 |
232 | 233 | 237 |
238 |
239 | 240 | 247 |
248 |
249 | 250 |
251 | 252 | 253 |

254 |
255 |
256 | 257 | 258 | -------------------------------------------------------------------------------- /src/content.ts: -------------------------------------------------------------------------------- 1 | import { Point } from './Point'; 2 | import { Renderer } from './Renderer'; 3 | import { FocusManager } from './FocusManager'; 4 | import { Drawer } from './draw/Drawer'; 5 | import { DrawStrategy } from './draw/DrawStrategy.enum'; 6 | import { OutlineDrawer } from './draw/OutlineDrawer'; 7 | import { FixedUnderlineDrawer } from './draw/FixedUnderlineDrawer'; 8 | import { UnderlineDrawer } from './draw/UnderlineDrawer'; 9 | import { MergedOutlineDrawer } from './draw/MergedOutlineDrawer'; 10 | import { SpotlightDrawer } from './draw/SpotlightDrawer'; 11 | import { ConfigManager } from './config/ConfigManager'; 12 | import { AnchorDrawInfo } from './AnchorDrawInfo'; 13 | import { FirstCharOutlineDrawer } from './draw/FirstCharOutlineDrawer'; 14 | import { HighlighterDrawer } from './draw/HighlighterDrawer'; 15 | import { FirstCharHighlighterDrawer } from './draw/FirstCharHighlighterDrawer'; 16 | import { BracketDrawer } from './draw/BracketDrawer'; 17 | import { DrawOption } from './draw/DrawOption'; 18 | import { Config } from './config/Config'; 19 | import { Idle } from 'idlejs'; 20 | 21 | const renderer = new Renderer(); 22 | const focusManager = new FocusManager(); 23 | 24 | const drawerMap = new Map([ 25 | [DrawStrategy.Underline, new UnderlineDrawer()], 26 | [DrawStrategy.FixedUnderline, new FixedUnderlineDrawer()], 27 | [DrawStrategy.Outline, new OutlineDrawer()], 28 | [DrawStrategy.MergedOutline, new MergedOutlineDrawer()], 29 | [DrawStrategy.Spotlight, new SpotlightDrawer()], 30 | [DrawStrategy.FirstCharOutline, new FirstCharOutlineDrawer()], 31 | [DrawStrategy.Highlighter, new HighlighterDrawer()], 32 | [DrawStrategy.FirstCharHighlighter, new FirstCharHighlighterDrawer()], 33 | [DrawStrategy.Bracket, new BracketDrawer()], 34 | ]); 35 | 36 | let idle: Idle; 37 | let movedAfterFocus = false; 38 | let lastMouseMoveClientX: number, lastMouseMoveClientY: number; 39 | 40 | let focusActive = false; 41 | const config = ConfigManager.getInstance(); 42 | 43 | async function init() { 44 | loadStorageConfigs(); 45 | 46 | initIdle(); 47 | 48 | renderer.updateCanvasZIndex(focusManager.maxZIndex + 1); 49 | 50 | update(); 51 | } 52 | 53 | async function update() { 54 | focusManager.init(); 55 | renderer.updateCanvasSize(); 56 | } 57 | 58 | async function updateConfig(newConfig: any) { 59 | Config.assignProperties(config, newConfig); 60 | idle.within(config.focusOnCursorStayTimeMillis.value, 1); 61 | 62 | if (focusActive) { 63 | registerFrameDrawSchedule(); 64 | focusManager.scrollToFocusedAnchor(); 65 | } 66 | } 67 | 68 | function initIdle(): void { 69 | const idleTimeOutMillis = 700; 70 | 71 | idle = new Idle() 72 | .whenNotInteractive() 73 | .within(idleTimeOutMillis, 1) 74 | .do(() => { 75 | idleEvent(); 76 | }); 77 | } 78 | 79 | function activateFocus(): void { 80 | update(); 81 | renderer.showToast('Focus activated', 1000, config.toastOption.selected, 'rgba(0, 128, 0, 0.5)'); 82 | idle.start(); 83 | } 84 | function deactivateFocus(): void { 85 | renderer.clearCanvas(); 86 | renderer.showToast( 87 | 'Focus deactivated', 88 | 1000, 89 | config.toastOption.selected, 90 | 'rgba(196, 64, 0, 0.5)', 91 | ); 92 | idle.stop(); 93 | } 94 | 95 | function drawFocusAnchor(): void { 96 | const sentenceRects = focusManager.getSentenceRects(); 97 | if (sentenceRects.length == 0) return; 98 | 99 | const firstCharRect = focusManager.getFirstCharRect(); 100 | 101 | renderer.clearCanvas(); 102 | 103 | const drawer = drawerMap.get(config.drawStrategy.selected); 104 | 105 | if (!drawer) { 106 | console.warn(`Cannot found drawer`); 107 | return; 108 | } 109 | 110 | const anchorDrawInfo = new AnchorDrawInfo(sentenceRects, firstCharRect); 111 | const drawOption = new DrawOption( 112 | config.drawColor.selected, 113 | config.opacity.value, 114 | config.lineWidth.value, 115 | config.borderRadius.value, 116 | ); 117 | 118 | drawer.draw(renderer, anchorDrawInfo, drawOption); 119 | } 120 | 121 | let nextFrameDrawScheduled = false; 122 | function registerFrameDrawSchedule(): void { 123 | if (nextFrameDrawScheduled) return; 124 | nextFrameDrawScheduled = true; 125 | 126 | requestAnimationFrame(() => { 127 | drawFocusAnchor(); 128 | nextFrameDrawScheduled = false; 129 | }); 130 | } 131 | 132 | window.addEventListener('resize', () => { 133 | if (!focusActive) return; 134 | 135 | renderer.updateCanvasSize(); 136 | registerFrameDrawSchedule(); 137 | }); 138 | 139 | document.addEventListener( 140 | 'scroll', 141 | function (e) { 142 | if (!focusActive) return; 143 | registerFrameDrawSchedule(); 144 | }, 145 | true, 146 | ); 147 | 148 | document.addEventListener('mousemove', async function (e) { 149 | lastMouseMoveClientX = e.clientX; 150 | lastMouseMoveClientY = e.clientY; 151 | movedAfterFocus = true; 152 | }); 153 | 154 | document.addEventListener('click', async function (e) { 155 | if (!e.target || !(e.target instanceof Element)) { 156 | console.debug("Type of clicked target is not 'Node'"); 157 | return; 158 | } 159 | 160 | mouseFocus(e.target, e.clientX, e.clientY); 161 | }); 162 | 163 | async function idleEvent() { 164 | if (config.useFocusOnCursorStay.selected === 'false') return; 165 | 166 | // console.debug(`idle!`); 167 | 168 | if (!movedAfterFocus) { 169 | return; 170 | } 171 | 172 | const element = document.elementFromPoint(lastMouseMoveClientX, lastMouseMoveClientY); 173 | if (!element) { 174 | // console.debug( 175 | // `Return value of elementFromPoint(${lastMouseMoveClientX}, ${lastMouseMoveClientY}) is null.`, 176 | // ); 177 | return; 178 | } 179 | 180 | mouseFocus(element, lastMouseMoveClientX, lastMouseMoveClientY); 181 | } 182 | 183 | function isFocusedOnEditableNode(): boolean { 184 | switch (document.activeElement?.nodeName) { 185 | case 'input': 186 | case 'textarea': 187 | return true; 188 | } 189 | if ( 190 | document.activeElement && 191 | document.activeElement instanceof HTMLElement && 192 | document.activeElement.isContentEditable 193 | ) { 194 | return true; 195 | } 196 | 197 | return false; 198 | } 199 | 200 | function isHotkeyMatch(event: KeyboardEvent, hotkey: string): boolean { 201 | const parts = hotkey.split('+').map((p) => p.trim()); 202 | let key = parts.pop(); // The last part is the key 203 | 204 | const modifiers = { 205 | control: parts.includes('Control'), 206 | shift: parts.includes('Shift'), 207 | alt: parts.includes('Alt'), 208 | meta: parts.includes('Meta'), // Command key on Mac 209 | }; 210 | 211 | let eventKey = event.key; 212 | if (eventKey.length === 1 && eventKey.match(/[a-zA-Z]/)) { 213 | eventKey = eventKey.toUpperCase(); 214 | } 215 | if (eventKey === ' ') { 216 | eventKey = 'Space'; 217 | } 218 | 219 | return ( 220 | eventKey === key && 221 | event.ctrlKey === modifiers.control && 222 | event.shiftKey === modifiers.shift && 223 | event.altKey === modifiers.alt && 224 | event.metaKey === modifiers.meta 225 | ); 226 | } 227 | 228 | async function mouseFocus(targetElement: Element, clientX: number, clientY: number) { 229 | if (!focusActive) return; 230 | 231 | // focusManager.printInfo(clickedNode as Node); 232 | 233 | const focusMoved = focusManager.moveFocusFromClickInfo( 234 | targetElement, 235 | new Point(clientX, clientY), 236 | ); 237 | if (!focusMoved) return; 238 | 239 | if (!focusManager.existsAnchorRects()) return; 240 | 241 | movedAfterFocus = false; 242 | 243 | registerFrameDrawSchedule(); 244 | focusManager.scrollToFocusedAnchor(); 245 | } 246 | 247 | function toggleFocus(): void { 248 | focusActive = !focusActive; 249 | 250 | if (focusActive) { 251 | document.documentElement.classList.add('focus-anchor__active'); 252 | activateFocus(); 253 | } else { 254 | document.documentElement.classList.remove('focus-anchor__active'); 255 | deactivateFocus(); 256 | } 257 | } 258 | 259 | function sendToggleFocus(): void { 260 | chrome.runtime.sendMessage({ type: 'request-toggle-focus' }); 261 | } 262 | 263 | document.addEventListener('keydown', function (e) { 264 | if (isFocusedOnEditableNode()) return; 265 | 266 | if (isHotkeyMatch(e, config.toggleHotkey)) { 267 | e.preventDefault(); 268 | sendToggleFocus(); 269 | return; 270 | } 271 | 272 | if (!focusActive) return; 273 | 274 | let moveDir = 0; 275 | 276 | if (isHotkeyMatch(e, config.movePrevHotkey)) { 277 | moveDir = -1; 278 | } 279 | if (isHotkeyMatch(e, config.moveNextHotkey)) { 280 | moveDir = 1; 281 | } 282 | 283 | if (moveDir == 0) return; 284 | 285 | e.preventDefault(); 286 | 287 | movedAfterFocus = false; 288 | 289 | // 만약 도착한 앵커에 렌더링할 사각형이 존재하지 않는다면 현재 위치가 유효한 지점이 아니라고 판단하고 다시 이동. 290 | const movedOnce = focusManager.moveFocus(moveDir); 291 | if (!movedOnce) return; 292 | while (!focusManager.existsAnchorRects()) { 293 | const moved = focusManager.moveFocus(moveDir); 294 | if (!moved) break; 295 | } 296 | 297 | registerFrameDrawSchedule(); 298 | focusManager.scrollToFocusedAnchor(); 299 | }); 300 | 301 | chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { 302 | if (msg.type === 'toggle-focus') { 303 | toggleFocus(); 304 | sendResponse({ isActive: focusActive }); 305 | } 306 | if (msg.type === 'get-focus-state') { 307 | sendResponse({ isActive: focusActive }); 308 | } 309 | }); 310 | 311 | chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { 312 | if (msg.type === 'reload') { 313 | update(); 314 | } 315 | }); 316 | 317 | chrome.storage.onChanged.addListener((change, area) => { 318 | if (area === 'local' && change.config) { 319 | if (change.config.newValue) { 320 | updateConfig(change.config.newValue); 321 | } 322 | } 323 | }); 324 | 325 | async function loadStorageConfigs() { 326 | const { config: storedConfig } = await chrome.storage.local.get('config'); 327 | if (storedConfig) { 328 | updateConfig(storedConfig); 329 | } 330 | } 331 | 332 | /* 333 | debug.ts 테스트용. 334 | 335 | document.addEventListener("DOMContentLoaded", function (e) { 336 | console.debug("hi!"); 337 | activateFocus(); 338 | }); 339 | */ 340 | 341 | init(); 342 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { ColorConfigItem } from './config/ColorConfigItem'; 2 | import { Config } from './config/Config'; 3 | import { DropdownConfigItem } from './config/DropdownConfigItem'; 4 | import { NumberConfigItem } from './config/NumberConfigItem'; 5 | import { Utils } from './Utils'; 6 | import { getReleaseNoteHtml } from './releaseNote'; 7 | 8 | // Main container elements 9 | const mainContainer = document.getElementById('main-container')!; 10 | const focusToggleButton = document.getElementById('focus-toggle')!; 11 | const reloadButton = document.getElementById('reload')!; 12 | const resetButton = document.getElementById('reset') as HTMLButtonElement; 13 | const state = document.getElementById('state') as HTMLElement; 14 | const releaseNoteButton = document.getElementById('release-note-button')!; 15 | 16 | // Release Note elements 17 | const releaseNoteContainer = document.getElementById('release-note-container')!; 18 | const backToMainButton = document.getElementById('back-to-main')!; 19 | const releaseNote = document.getElementById('release-note')!; 20 | 21 | let activeHotkeyInputId: string | null = null; 22 | 23 | const hotkeyConfigKeys = ['toggleHotkey', 'movePrevHotkey', 'moveNextHotkey']; 24 | 25 | const keyDisplayMap: { [key: string]: string } = { 26 | ArrowUp: '↑', 27 | ArrowDown: '↓', 28 | ArrowLeft: '←', 29 | ArrowRight: '→', 30 | }; 31 | 32 | const storageKeyMap = Object.fromEntries(Object.entries(keyDisplayMap).map(([k, v]) => [v, k])); 33 | 34 | function flash(text: string, error = false): void { 35 | state.textContent = text; 36 | state.style.color = error ? 'red' : 'green'; 37 | 38 | setTimeout(() => { 39 | state.textContent = ''; 40 | state.style.color = ''; 41 | }, 5000); 42 | } 43 | 44 | function getStorageKey(eventKey: string): string { 45 | if (eventKey.length === 1 && eventKey.match(/[a-zA-Z]/)) { 46 | return eventKey.toUpperCase(); 47 | } 48 | if (eventKey === ' ') { 49 | return 'Space'; 50 | } 51 | return eventKey; 52 | } 53 | 54 | function formatHotkeyForDisplay(hotkey: string): string { 55 | return hotkey 56 | .split('+') 57 | .map((part) => keyDisplayMap[part] || part) 58 | .join('+'); 59 | } 60 | 61 | function initHotkeySetup(buttonId: string, inputId: string) { 62 | const button = document.getElementById(buttonId) as HTMLButtonElement; 63 | const input = document.getElementById(inputId) as HTMLInputElement; 64 | 65 | button.addEventListener('click', () => { 66 | activeHotkeyInputId = input.id; 67 | input.value = 'Press a key combination...'; 68 | input.focus(); 69 | }); 70 | 71 | input.addEventListener('keydown', (e) => { 72 | if (activeHotkeyInputId !== input.id) return; 73 | 74 | e.preventDefault(); 75 | e.stopPropagation(); 76 | 77 | const hotkeyParts: string[] = []; 78 | if (e.ctrlKey) hotkeyParts.push('Control'); 79 | if (e.shiftKey) hotkeyParts.push('Shift'); 80 | if (e.altKey) hotkeyParts.push('Alt'); 81 | if (e.metaKey) hotkeyParts.push('Meta'); 82 | 83 | let newHotkey = ''; 84 | 85 | if (['Control', 'Shift', 'Alt', 'Meta'].includes(e.key)) { 86 | newHotkey = hotkeyParts.join('+'); 87 | if (newHotkey) { 88 | input.value = newHotkey + '...'; 89 | } else { 90 | input.value = 'Press a key combination...'; 91 | } 92 | return; 93 | } 94 | 95 | const storageKey = getStorageKey(e.key); 96 | hotkeyParts.push(storageKey); 97 | newHotkey = hotkeyParts.join('+'); 98 | 99 | if (newHotkey) { 100 | input.value = formatHotkeyForDisplay(newHotkey); 101 | saveConfigFromElements(); 102 | activeHotkeyInputId = null; 103 | flash('Hotkey set successfully!'); 104 | } else { 105 | flash('Invalid hotkey. Try again.', true); 106 | } 107 | }); 108 | 109 | input.addEventListener('blur', () => { 110 | if (activeHotkeyInputId === input.id) { 111 | activeHotkeyInputId = null; 112 | loadStorageConfigs(); 113 | flash('Hotkey setting cancelled.', true); 114 | } 115 | }); 116 | } 117 | 118 | function updateIndicator(color: string) { 119 | focusToggleButton.style.backgroundColor = color; 120 | if (color === 'chartreuse') { 121 | focusToggleButton.style.color = '#333'; 122 | } else { 123 | focusToggleButton.style.color = '#333'; 124 | } 125 | } 126 | 127 | function checkRuntimeError(): boolean { 128 | if (chrome.runtime.lastError) { 129 | console.debug('No content script found or message failed:', chrome.runtime.lastError.message); 130 | return true; 131 | } 132 | return false; 133 | } 134 | 135 | async function checkForUpdates() { 136 | const { releaseNoteChecked } = await chrome.storage.local.get('releaseNoteChecked'); 137 | const badge = releaseNoteButton.querySelector('.notification-badge') as HTMLElement; 138 | if (badge && !releaseNoteChecked) { 139 | badge.style.display = 'block'; 140 | } 141 | } 142 | 143 | async function showReleaseNote() { 144 | releaseNote.innerHTML = await getReleaseNoteHtml(); 145 | 146 | mainContainer.style.display = 'none'; 147 | releaseNoteContainer.style.display = 'block'; 148 | releaseNoteContainer.style.width = '600px'; 149 | 150 | chrome.storage.local.set({ releaseNoteChecked: true }); 151 | const badge = releaseNoteButton.querySelector('.notification-badge') as HTMLElement; 152 | if (badge) { 153 | badge.style.display = 'none'; 154 | } 155 | } 156 | 157 | function showMainContent() { 158 | mainContainer.style.display = 'block'; 159 | releaseNoteContainer.style.display = 'none'; 160 | } 161 | 162 | // --- Event Listeners --- 163 | 164 | focusToggleButton.addEventListener('click', async () => { 165 | const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); 166 | chrome.tabs.sendMessage(tab.id!, { type: 'toggle-focus' }, (resp) => { 167 | if (checkRuntimeError()) return; 168 | if (!resp) return; 169 | updateIndicator(resp.isActive ? 'chartreuse' : 'darkorange'); 170 | }); 171 | }); 172 | 173 | reloadButton.addEventListener('click', async () => { 174 | const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); 175 | chrome.tabs.sendMessage(tab.id!, { type: 'reload' }, (resp) => { 176 | if (checkRuntimeError()) return; 177 | }); 178 | }); 179 | 180 | releaseNoteButton.addEventListener('click', async () => { 181 | showReleaseNote(); 182 | }); 183 | 184 | backToMainButton.addEventListener('click', showMainContent); 185 | 186 | resetButton.onclick = () => { 187 | loadConfigToElements(Config.default); 188 | chrome.storage.local.set({ config: Config.default }); 189 | flash('✔ Reseted!'); 190 | }; 191 | 192 | chrome.storage.onChanged.addListener((change, area) => { 193 | if (area === 'local' && change.config) { 194 | loadStorageConfigs(); 195 | } 196 | }); 197 | 198 | document.addEventListener('DOMContentLoaded', async () => { 199 | const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); 200 | 201 | chrome.tabs.sendMessage(tab.id!, { type: 'get-focus-state' }, (resp) => { 202 | if (checkRuntimeError()) return; 203 | if (!resp) return; 204 | updateIndicator(resp.isActive ? 'chartreuse' : 'darkorange'); 205 | }); 206 | 207 | loadStorageConfigs(); 208 | checkForUpdates(); 209 | 210 | const config = Config.default; 211 | for (const key of Object.keys(config) as (keyof Config)[]) { 212 | const element = document.getElementById(`config-${key}`); 213 | if (!element) continue; 214 | 215 | if (hotkeyConfigKeys.includes(key)) { 216 | continue; 217 | } 218 | 219 | if (config[key] instanceof DropdownConfigItem) { 220 | element.addEventListener('change', () => saveConfigFromElements()); 221 | } else { 222 | element.addEventListener('input', () => saveConfigFromElements()); 223 | } 224 | } 225 | 226 | initHotkeySetup('set-hotkey', 'config-toggleHotkey'); 227 | initHotkeySetup('set-move-prev-hotkey', 'config-movePrevHotkey'); 228 | initHotkeySetup('set-move-next-hotkey', 'config-moveNextHotkey'); 229 | }); 230 | 231 | function loadStorageConfigs() { 232 | chrome.storage.local.get('config').then(({ config }) => { 233 | if (config) { 234 | const storageConfig = Config.from(config); 235 | loadConfigToElements(storageConfig); 236 | } else { 237 | loadConfigToElements(Config.default); 238 | saveConfigFromElements(); 239 | } 240 | }); 241 | } 242 | 243 | function loadConfigToElements(config: Config): void { 244 | for (const key of Object.keys(config) as (keyof Config)[]) { 245 | const element = document.getElementById(`config-${key}`); 246 | if (!element) continue; 247 | 248 | if (hotkeyConfigKeys.includes(key)) { 249 | (element as HTMLInputElement).value = formatHotkeyForDisplay(config[key] as string); 250 | } else if (config[key] instanceof NumberConfigItem) { 251 | (element as HTMLInputElement).value = String(config[key].value); 252 | } else if (config[key] instanceof DropdownConfigItem) { 253 | (element as HTMLSelectElement).value = String(config[key].selected); 254 | } else if (config[key] instanceof ColorConfigItem) { 255 | (element as HTMLInputElement).value = config[key].selected; 256 | } else if (typeof config[key] === 'string') { 257 | (element as HTMLInputElement).value = config[key]; 258 | } 259 | } 260 | } 261 | 262 | function saveConfigFromElements() { 263 | const config = Config.default; 264 | 265 | for (const key of Object.keys(config) as (keyof Config)[]) { 266 | const element = document.getElementById(`config-${key}`); 267 | if (!element) continue; 268 | 269 | if (hotkeyConfigKeys.includes(key)) { 270 | const displayValue = (element as HTMLInputElement).value; 271 | (config[key] as string) = displayValue 272 | .split('+') 273 | .map((part) => storageKeyMap[part] || part) 274 | .join('+'); 275 | } else if (config[key] instanceof NumberConfigItem) { 276 | config[key].value = Utils.clamp( 277 | Number((element as HTMLInputElement).value), 278 | config[key].minValue, 279 | config[key].maxValue, 280 | ); 281 | } else if (config[key] instanceof DropdownConfigItem) { 282 | config[key].selected = (element as HTMLSelectElement).value; 283 | } else if (config[key] instanceof ColorConfigItem) { 284 | config[key].selected = (element as HTMLInputElement).value; 285 | } else if (typeof config[key] === 'string') { 286 | (config[key] as string) = (element as HTMLInputElement).value; 287 | } 288 | } 289 | 290 | chrome.storage.local.set({ config: config }); 291 | } 292 | -------------------------------------------------------------------------------- /src/FocusManager.ts: -------------------------------------------------------------------------------- 1 | import { Anchor } from './Anchor'; 2 | import { ConfigManager } from './config/ConfigManager'; 3 | import { SimpleDelimitPattern } from './SimpleDelimitPattern'; 4 | import { FocusInfo } from './FocusInfo'; 5 | import { Fragment } from './Fragment'; 6 | import { Point } from './Point'; 7 | import { Rect } from './Rect'; 8 | import { DelimitPattern } from './DelimitPattern'; 9 | 10 | export class FocusManager { 11 | private nodeList: Node[] = []; 12 | private nodeIdxMap = new Map(); 13 | private anchorMap = new Map(); 14 | 15 | private nonSplitTagList: RegExp[] = [ 16 | /^a$/i, 17 | /^b$/i, 18 | /^del$/i, 19 | /^em$/i, 20 | /^font$/i, 21 | /^i$/i, 22 | /^ins$/i, 23 | /^mark$/i, 24 | /^s$/i, 25 | /^span$/i, 26 | /^strong$/i, 27 | /^sup$/i, 28 | /^sub$/i, 29 | /^u$/i, 30 | ]; 31 | private ignoreTagList: RegExp[] = [/^script$/i, /^code$/i, /^#comment$/i]; 32 | private ignoreClassList: RegExp[] = [/^mjx/i, /^MathJax/i]; 33 | private delimitPatterns: DelimitPattern[] = [ 34 | new DelimitPattern((str) => { 35 | const regexp = /\. /g; 36 | 37 | let execResult = regexp.exec(str); 38 | if (execResult == null) { 39 | return false; 40 | } 41 | const lastIndex = regexp.lastIndex; 42 | 43 | execResult = regexp.exec(str); 44 | if (execResult == null && /^[0-9]+\. /.test(str.substring(0, lastIndex))) { 45 | return false; 46 | } 47 | if (execResult == null && /^[0-9]+\.[0-9]+\. /.test(str.substring(0, lastIndex))) { 48 | return false; 49 | } 50 | if (execResult == null && /^[0-9]+\.[0-9]+\.[0-9]+\. /.test(str.substring(0, lastIndex))) { 51 | return false; 52 | } 53 | 54 | return true; 55 | }, 1), 56 | new SimpleDelimitPattern(/。/, 0), 57 | new SimpleDelimitPattern(/\.\n/, 1), 58 | new SimpleDelimitPattern(/\. /, 1), 59 | new SimpleDelimitPattern(/\? /, 1), 60 | new SimpleDelimitPattern(/\?\n/, 1), 61 | new SimpleDelimitPattern(/! /, 1), 62 | new SimpleDelimitPattern(/!\n/, 1), 63 | new SimpleDelimitPattern(/\n\n/, 1), 64 | ]; 65 | 66 | private focusInfo = new FocusInfo(0, 0); 67 | private config = ConfigManager.getInstance(); 68 | 69 | private readonly floorMergeTestRange = 10; 70 | private readonly minRectArea = 100; 71 | 72 | private readonly maxTextContentLength: number = 10_000; 73 | 74 | public maxZIndex = 9999; 75 | 76 | init() { 77 | this.nodeList.splice(0, this.nodeList.length); 78 | this.nodeIdxMap.clear(); 79 | this.anchorMap.clear(); 80 | 81 | // const startTime = Date.now(); 82 | this.traverseAndExtract(document.body, []); 83 | // console.debug(`Extracted text nodes. elapsed => ${Date.now() - startTime}ms`); 84 | 85 | for (let i = 0; i < this.nodeList.length; i++) { 86 | this.nodeIdxMap.set(this.nodeList[i], i); 87 | } 88 | 89 | // 최소 영역 만족 못하거나 보이지 않는 앵커 제거. 90 | this.anchorMap.forEach((anchors, node) => { 91 | this.anchorMap.set( 92 | node, 93 | anchors.filter((anchor) => { 94 | if (this.getRectsAreaOfAnchor(anchor) < this.minRectArea) { 95 | return false; 96 | } 97 | return true; 98 | }), 99 | ); 100 | }); 101 | 102 | // 빈 앵커리스트를 앵커맵에서 제거. 103 | const deleteAnchorsKeys: number[] = []; 104 | for (const entry of this.anchorMap.entries()) { 105 | const nodeIdx = entry[0]; 106 | const anchors = entry[1]; 107 | 108 | if (anchors.length == 0) { 109 | deleteAnchorsKeys.push(nodeIdx); 110 | } 111 | } 112 | for (const nodeIdx of deleteAnchorsKeys) { 113 | this.anchorMap.delete(nodeIdx); 114 | } 115 | 116 | // 초기 인덱스 지정. 117 | let firstNodeIdx = -1; 118 | for (const key of this.anchorMap.keys()) { 119 | if (firstNodeIdx == -1 || key < firstNodeIdx) { 120 | firstNodeIdx = key; 121 | } 122 | } 123 | this.focusInfo.nodeIdx = firstNodeIdx; 124 | this.focusInfo.anchorIdx = 0; 125 | 126 | // for (let i = 0; i < this.nodeList.length; i++) { 127 | // console.debug(`nodeList[${i}].text=${this.nodeList[i].textContent}`); 128 | // } 129 | 130 | // for (const nodeIdx of this.anchorMap.keys()) { 131 | // console.debug(`nodeList[${nodeIdx}]`); 132 | // for (const anchor of this.anchorMap.get(nodeIdx)!) { 133 | // console.debug(`anchor=${anchor}`); 134 | // } 135 | // } 136 | // console.debug(`nodeList.length=${this.nodeList.length}`); 137 | } 138 | 139 | private hasMatchingClass(element: Element, regexpList: RegExp[]): boolean { 140 | for (const regexp of regexpList) { 141 | let matched = false; 142 | element.classList.forEach((clazz) => { 143 | matched = matched || regexp.test(clazz); 144 | }); 145 | if (matched) { 146 | return true; 147 | } 148 | } 149 | return false; 150 | } 151 | 152 | private shouldSplitOnNode(node: Node): boolean { 153 | return this.nonSplitTagList.some((regexp) => regexp.test(node.nodeName)); 154 | } 155 | 156 | private shouldIgnoreNode(node: Node): boolean { 157 | if (this.ignoreTagList.some((regexp) => regexp.test(node.nodeName))) { 158 | return true; 159 | } 160 | if (node instanceof Element && this.hasMatchingClass(node, this.ignoreClassList)) { 161 | return true; 162 | } 163 | return false; 164 | } 165 | 166 | private updateMaxZIfElementZHigher(element: Element) { 167 | const zIndex = parseInt(getComputedStyle(element).zIndex); 168 | if (zIndex) { 169 | this.maxZIndex = Math.max(this.maxZIndex, zIndex); 170 | } 171 | } 172 | 173 | private extractTextFromNode(node: Node, fragmentList: Fragment[]) { 174 | const parentElement = node.parentElement!; 175 | const style = getComputedStyle(parentElement); 176 | if ( 177 | style.display === 'none' || 178 | style.visibility === 'hidden' || 179 | parseFloat(style.opacity) === 0 || 180 | parseFloat(style.width) * parseFloat(style.height) < this.minRectArea || 181 | parentElement.hasAttribute('hidden') || 182 | parentElement.getAttribute('aria-hidden') === 'true' 183 | ) { 184 | return; 185 | } 186 | 187 | // 텍스트 노드로부터 텍스트 조각 획득. 188 | if (node.textContent && node.textContent.length <= this.maxTextContentLength) { 189 | for (let i = 0; i < node.textContent.length; i++) { 190 | fragmentList.push(new Fragment(node.textContent[i], node, i)); 191 | } 192 | } 193 | return; 194 | } 195 | 196 | private traverseAndExtract(element: Element, fragmentList: Fragment[]): void { 197 | element.childNodes.forEach((child) => { 198 | if (child instanceof Element) { 199 | this.updateMaxZIfElementZHigher(child); 200 | } 201 | 202 | if (this.shouldIgnoreNode(child)) { 203 | return; 204 | } 205 | 206 | this.nodeList.push(child); 207 | this.nodeIdxMap.set(child, this.nodeList.length - 1); 208 | 209 | // 탐색 대상이 텍스트 노드라면, 텍스트 조각으로 획득해서 스택 최상단에 삽입. 210 | if (child.nodeType == Node.TEXT_NODE) { 211 | this.extractTextFromNode(child, fragmentList); 212 | return; 213 | } 214 | 215 | if (!(child instanceof Element)) { 216 | console.debug(`element ${element.nodeName} is not instance of element`); 217 | return; 218 | } 219 | 220 | // 비분리 태그가 아니라면 상위 노드와 분리가 필요하므로 스택에 새 리스트 추가. 221 | if (this.shouldSplitOnNode(child)) { 222 | this.traverseAndExtract(child, fragmentList); 223 | } else { 224 | this.traverseAndExtract(child, []); 225 | } 226 | 227 | // 만약 비분리 태그가 아닐 경우 텍스트 조각이 이어져 해석되면 안되므로 구분용 조각 추가. 228 | if (!this.shouldSplitOnNode(child)) { 229 | fragmentList.push(new Fragment('', child, -1)); 230 | } 231 | }); 232 | 233 | // 비분리 태그라면 상위 노드에서 해석해야하므로 반환. 234 | if (this.shouldSplitOnNode(element)) { 235 | return; 236 | } 237 | 238 | // 스택 최상단의 조각들을 이어붙여 해석. 239 | this.extractAnchorFromFragments(fragmentList); 240 | } 241 | 242 | private extractAnchorFromFragments(fragmentList: Fragment[]) { 243 | let fragmentBuffer: Fragment[] = []; 244 | let stringBuffer = ''; 245 | 246 | for (const fragment of fragmentList) { 247 | if (fragment.idx != -1) { 248 | fragmentBuffer.push(fragment); 249 | stringBuffer += fragment.ch; 250 | } 251 | 252 | let needSplit = false; 253 | 254 | // 구분자를 통해 이어붙인 조각들이 문장으로 분리되어야 하는지 검사. 255 | for (const delimitPattern of this.delimitPatterns) { 256 | let matchSucceed = delimitPattern.test(stringBuffer); 257 | 258 | if (matchSucceed) { 259 | let popCount = delimitPattern.exclusionCountFromEnd; 260 | while (popCount--) { 261 | fragmentBuffer.pop(); 262 | } 263 | 264 | needSplit = true; 265 | break; 266 | } 267 | } 268 | 269 | // 구분용 조각인 경우. 270 | if (fragment.idx == -1) { 271 | needSplit = true; 272 | } 273 | 274 | if (!needSplit) continue; 275 | 276 | // 노드의 앵커인덱스를 Map에 삽입. 277 | // 공백 문자뿐인 문장은 필요없으므로 검사. 278 | // 텍스트 없는 분리 태그가 첫번째 조각으로 오면 길이가 0일수도 있으므로 검사. 279 | if (fragmentBuffer.length > 0 && stringBuffer.trim()) { 280 | const firstFragmentNodeIdx = this.nodeIdxMap.get(fragmentBuffer[0].node)!; 281 | const lastFragmentNodeIdx = this.nodeIdxMap.get( 282 | fragmentBuffer[fragmentBuffer.length - 1].node, 283 | )!; 284 | 285 | if (!this.anchorMap.get(firstFragmentNodeIdx)) { 286 | this.anchorMap.set(firstFragmentNodeIdx, []); 287 | } 288 | this.anchorMap 289 | .get(firstFragmentNodeIdx)! 290 | .push( 291 | new Anchor( 292 | firstFragmentNodeIdx, 293 | fragmentBuffer[0].idx, 294 | lastFragmentNodeIdx, 295 | fragmentBuffer[fragmentBuffer.length - 1].idx + 1, 296 | ), 297 | ); 298 | } 299 | fragmentBuffer = []; 300 | stringBuffer = ''; 301 | } 302 | 303 | // 마지막 문장이 프레임버퍼에 남아있을 수 있으므로 처리. 304 | if (fragmentBuffer.length > 0 && stringBuffer.trim()) { 305 | const firstFragmentNodeIdx = this.nodeIdxMap.get(fragmentBuffer[0].node)!; 306 | const lastFragmentNodeIdx = this.nodeIdxMap.get( 307 | fragmentBuffer[fragmentBuffer.length - 1].node, 308 | )!; 309 | 310 | if (!this.anchorMap.has(firstFragmentNodeIdx)) { 311 | this.anchorMap.set(firstFragmentNodeIdx, []); 312 | } 313 | this.anchorMap 314 | .get(firstFragmentNodeIdx)! 315 | .push( 316 | new Anchor( 317 | firstFragmentNodeIdx, 318 | fragmentBuffer[0].idx, 319 | lastFragmentNodeIdx, 320 | fragmentBuffer[fragmentBuffer.length - 1].idx + 1, 321 | ), 322 | ); 323 | } 324 | } 325 | 326 | private getRectFromDomRect(domRect: DOMRect): Rect { 327 | return new Rect(domRect.x, domRect.y, domRect.width, domRect.height); 328 | } 329 | 330 | private getRectsFromAnchor(anchor: Anchor): Rect[] { 331 | const range = document.createRange(); 332 | range.setStart(this.nodeList[anchor.startNodeIdx], anchor.startOffsetIdx); 333 | range.setEnd(this.nodeList[anchor.endNodeIdx], anchor.endOffsetIdx); 334 | 335 | if (typeof range.getClientRects !== 'function') { 336 | console.warn('getClientRects를 지원하지 않는 환경입니다.'); 337 | return []; 338 | } 339 | 340 | const domRects = range.getClientRects(); 341 | if (!domRects || domRects.length == 0) { 342 | // console.debug("getRectsFromAnchor: Failed to get client rects from a anchor"); 343 | return []; 344 | } 345 | 346 | const rects: Rect[] = []; 347 | for (const domRect of domRects) { 348 | rects.push(this.getRectFromDomRect(domRect)); 349 | } 350 | 351 | if (!rects || rects.length == 0) { 352 | console.warn('getRectsFromAnchor: Failed to get rects from domRects'); 353 | return []; 354 | } 355 | 356 | return rects; 357 | } 358 | 359 | private getFirstCharRectFromAnchor(anchor: Anchor): Rect | null { 360 | const range = document.createRange(); 361 | 362 | if (typeof range.getClientRects !== 'function') { 363 | console.warn('getClientRects를 지원하지 않는 환경입니다.'); 364 | return null; 365 | } 366 | 367 | range.setStart(this.nodeList[anchor.startNodeIdx], anchor.startOffsetIdx); 368 | 369 | let endNodeIdx = anchor.startNodeIdx; 370 | let endOffset = anchor.startOffsetIdx; 371 | this.anchorMap; 372 | while (endNodeIdx <= anchor.endNodeIdx) { 373 | const node = this.nodeList[endNodeIdx]; 374 | 375 | if (node.nodeType != Node.TEXT_NODE || !node.textContent) { 376 | endNodeIdx++; 377 | continue; 378 | } 379 | 380 | while (endOffset <= node.textContent.length) { 381 | range.setEnd(this.nodeList[endNodeIdx], endOffset); 382 | 383 | const domRects = range.getClientRects(); 384 | if (!domRects || domRects.length == 0) { 385 | endOffset++; 386 | continue; 387 | } 388 | 389 | const rect = this.getRectFromDomRect(domRects[0]); 390 | if (rect.width * rect.height < this.minRectArea) { 391 | endOffset++; 392 | continue; 393 | } 394 | 395 | return rect; 396 | } 397 | 398 | if (endOffset > node.textContent.length) { 399 | endNodeIdx++; 400 | endOffset = 0; 401 | } 402 | } 403 | 404 | return null; 405 | } 406 | 407 | private getRectsAreaOfAnchor(anchor: Anchor): number { 408 | const rects = this.getRectsFromAnchor(anchor); 409 | 410 | let totalRectArea = 0; 411 | for (const rect of rects) { 412 | totalRectArea += rect.width * rect.height; 413 | } 414 | 415 | return totalRectArea; 416 | } 417 | 418 | private getFloorSeperatedRectsFromAnchor(anchor: Anchor): Rect[] { 419 | const rects = this.getRectsFromAnchor(anchor); 420 | 421 | if (rects.length == 0) { 422 | // console.debug( 423 | // "getFloorSeperatedRectsFromAnchor: Failed to get rects from getRectsFromAnchor" 424 | // ); 425 | return []; 426 | } 427 | 428 | rects.sort((a, b) => { 429 | return a.y - b.y; 430 | }); 431 | 432 | const floorSeperatedRects: Rect[] = [rects[0]]; 433 | // 같은 층 사각형 병합 434 | for (let i = 1; i < rects.length; i++) { 435 | const rect = floorSeperatedRects[floorSeperatedRects.length - 1]; 436 | // 옆면이 겹치지 않는 경우: 바로 리스트에 추가. 437 | if ( 438 | Math.abs((rect.top + rect.bottom) / 2 - (rects[i].top + rects[i].bottom) / 2) > 439 | this.floorMergeTestRange 440 | ) { 441 | floorSeperatedRects.push(rects[i]); 442 | } 443 | // 옆면이 겹치는 경우. 바운딩 사각형 추가. 444 | else { 445 | // console.debug(`getFloorSeperatedRectsFromRects: Detected overlappnig rectangles, idx of rect is ${i}`); 446 | const newLeft = Math.min(rect.left, rects[i].left); 447 | const newTop = Math.min(rect.top, rects[i].top); 448 | const newRight = Math.max(rect.right, rects[i].right); 449 | const newBottom = Math.max(rect.bottom, rects[i].bottom); 450 | 451 | const newRect = new Rect(newLeft, newTop, newRight - newLeft, newBottom - newTop); 452 | floorSeperatedRects[floorSeperatedRects.length - 1] = newRect; 453 | } 454 | } 455 | 456 | return floorSeperatedRects; 457 | } 458 | 459 | private findFocusInfoFromClickInfo(node: Node, clickedPoint: Point): FocusInfo | null { 460 | // 텍스트를 클릭해도 clickedTarget에는 텍스트 노드가 아니라 그 상위 노드가 담김. 461 | // 앵커를 가진 노드는 모두 텍스트 노드이므로, 모든 노드에 대해 자식 노드로부터 영역 내 클릭 위치가 있는 앵커 탐색. 462 | for (const childNode of node.childNodes) { 463 | if (childNode.hasChildNodes()) { 464 | const res = this.findFocusInfoFromClickInfo(childNode, clickedPoint); 465 | if (res) return res; 466 | } 467 | 468 | const anchors = this.anchorMap.get(this.nodeIdxMap.get(childNode)!); 469 | if (!anchors) continue; 470 | 471 | for (let i = 0; i < anchors.length; i++) { 472 | const anchor = anchors[i]; 473 | const rects = this.getFloorSeperatedRectsFromAnchor(anchor); 474 | 475 | for (const rect of rects) { 476 | if ( 477 | clickedPoint.x >= rect.left && 478 | clickedPoint.x <= rect.right && 479 | clickedPoint.y >= rect.top && 480 | clickedPoint.y <= rect.bottom 481 | ) { 482 | return new FocusInfo(this.nodeIdxMap.get(childNode)!, i); 483 | } 484 | } 485 | } 486 | } 487 | 488 | return null; 489 | } 490 | 491 | private getFocusInfoFromClickInfo(clickedNodeIdx: number, clickedPoint: Point): FocusInfo | null { 492 | let pNode: Node | null = this.nodeList[clickedNodeIdx]; 493 | while (pNode && this.nonSplitTagList.some((regexp) => regexp.test(pNode!.nodeName))) { 494 | pNode = pNode.parentNode; 495 | } 496 | if (!pNode) { 497 | return null; 498 | } 499 | 500 | const res = this.findFocusInfoFromClickInfo(pNode, clickedPoint); 501 | if (res) return res; 502 | 503 | if (this.config.strictClickDetection.selected == 'true') return null; 504 | 505 | // 만약 정확한 앵커를 찾지 못했다면, 근접한 앵커 정보라도 반환. 506 | for (let pNodeIdx = clickedNodeIdx; pNodeIdx < this.nodeList.length; pNodeIdx++) { 507 | if (this.anchorMap.has(pNodeIdx)) { 508 | return new FocusInfo(pNodeIdx, 0); 509 | } 510 | } 511 | 512 | return null; 513 | } 514 | 515 | moveFocusFromClickInfo(clickedNode: Node, clickedPoint: Point): boolean { 516 | let clickedNodeIdx = this.nodeIdxMap.get(clickedNode); 517 | if (!clickedNodeIdx) return false; 518 | 519 | const newFocusInfo = this.getFocusInfoFromClickInfo(clickedNodeIdx, clickedPoint); 520 | if (!newFocusInfo) return false; 521 | 522 | this.focusInfo = newFocusInfo; 523 | 524 | return true; 525 | } 526 | 527 | moveFocus(offset: number): boolean { 528 | const dir = Math.sign(offset); 529 | let cnt = Math.abs(offset); 530 | 531 | while (cnt > 0) { 532 | // 다음 목적지가 현재 노드 내 앵커인덱스 리스트에 존재할 경우. 533 | if ( 534 | this.focusInfo.anchorIdx + dir >= 0 && 535 | this.focusInfo.anchorIdx + dir <= this.anchorMap.get(this.focusInfo.nodeIdx)!.length - 1 536 | ) { 537 | this.focusInfo.anchorIdx += dir; 538 | cnt--; 539 | continue; 540 | } 541 | 542 | // 다음 목적지가 현재 노드 내 앵커인덱스 리스트를 벗어나는 경우. 543 | let endOfNode = false; 544 | const nextfocusInfo = new FocusInfo(this.focusInfo.nodeIdx, this.focusInfo.anchorIdx); 545 | while (true) { 546 | if ( 547 | nextfocusInfo.nodeIdx + dir < 0 || 548 | nextfocusInfo.nodeIdx + dir > this.nodeList.length - 1 549 | ) { 550 | endOfNode = true; 551 | break; 552 | } 553 | 554 | if (!this.anchorMap.has(nextfocusInfo.nodeIdx + dir)) { 555 | // console.debug(`${nextfocusInfo.nodeIdx + dir} doesn't have anchorIndices`); 556 | nextfocusInfo.nodeIdx += dir; 557 | continue; 558 | } 559 | 560 | // 앵커인덱스를 가진 노드 발견하면 focus idx update. 561 | nextfocusInfo.nodeIdx += dir; 562 | if (dir > 0) { 563 | nextfocusInfo.anchorIdx = 0; 564 | } 565 | if (dir < 0) { 566 | nextfocusInfo.anchorIdx = this.anchorMap.get(nextfocusInfo.nodeIdx)!.length - 1; 567 | } 568 | break; 569 | } 570 | 571 | // 현재 노드가 마지막이라면 더 이상 이동하지 않고 종료. 572 | if (endOfNode) return false; 573 | 574 | this.focusInfo.nodeIdx = nextfocusInfo.nodeIdx; 575 | this.focusInfo.anchorIdx = nextfocusInfo.anchorIdx; 576 | 577 | cnt--; 578 | } 579 | 580 | return true; 581 | } 582 | 583 | private isScrollable(node: HTMLElement): boolean { 584 | const overflowY = getComputedStyle(node).overflowY; 585 | return ( 586 | (overflowY === 'scroll' || overflowY === 'auto') && node.scrollHeight > node.clientHeight 587 | ); 588 | } 589 | 590 | private getAnchorBoundingRect(anchor: Anchor): Rect { 591 | const range = document.createRange(); 592 | range.setStart(this.nodeList[anchor.startNodeIdx], anchor.startOffsetIdx); 593 | range.setEnd(this.nodeList[anchor.endNodeIdx], anchor.endOffsetIdx); 594 | return range.getBoundingClientRect(); 595 | } 596 | 597 | private scrollToAnchor(anchor: Anchor, bias: number): void { 598 | const node = this.nodeList[anchor.startNodeIdx]; 599 | const anchorRect = this.getAnchorBoundingRect(anchor); 600 | 601 | let scrollParent = node.parentElement; 602 | while (scrollParent) { 603 | if (this.isScrollable(scrollParent)) break; 604 | /* 605 | 포커스 노드의 스크롤 부모를 찾기 전에 fixed 속성을 가진 노드를 만났다면 606 | 스크롤 부모의 스크롤을 이동해도 포커스 노드의 위치는 변하지 않으므로 스크롤 취소. 607 | */ 608 | if (getComputedStyle(scrollParent).position == 'fixed') return; 609 | scrollParent = scrollParent.parentElement; 610 | } 611 | 612 | if (scrollParent === document.body || !scrollParent) { 613 | scrollParent = document.scrollingElement as HTMLElement; 614 | } 615 | 616 | const viewportHeight = window.innerHeight; 617 | 618 | // 노드가 최종적으로 위치해야 할 뷰포트 내 타겟 Y좌표. 619 | const targetY = viewportHeight * bias; 620 | const distFromAnchorToTargetY = anchorRect.top - targetY; 621 | 622 | const maxScrollTop = scrollParent.scrollHeight - scrollParent.clientHeight; 623 | const scrollParentTop = 624 | scrollParent === document.scrollingElement ? 0 : scrollParent.getBoundingClientRect().top; 625 | const scrollParentBottom = 626 | scrollParent === document.scrollingElement 627 | ? viewportHeight 628 | : scrollParent.getBoundingClientRect().bottom; 629 | 630 | // 최대로 증가가능한 스크롤 양 계산. 631 | // (스크롤바를 아래로 움직일 수 있는 양)과 (포커스된 문장이 스크롤로 인해 화면에서 사라지지 않는 최대 한도량) 중 더 작은값을 '스크롤 증가 한도량'으로 지정. 632 | const availableIncreaseScrollAmount = Math.min( 633 | maxScrollTop - scrollParent.scrollTop, 634 | Math.max(0, anchorRect.top - scrollParentTop), 635 | ); 636 | // 최대로 감소가능한 스크롤 양 계산. 637 | const availableDecreaseScrollAmount = Math.min( 638 | scrollParent.scrollTop, 639 | Math.max(0, scrollParentBottom - anchorRect.bottom), 640 | ); 641 | 642 | // 부모 스크롤 컨테이너를 최대한 움직였을 때 타겟 Y좌표까지 부족한 스크롤 계산. 643 | const deficientScroll = 644 | distFromAnchorToTargetY > 0 645 | ? Math.max(0, distFromAnchorToTargetY - availableIncreaseScrollAmount) 646 | : Math.max(0, Math.abs(distFromAnchorToTargetY) - availableDecreaseScrollAmount) * -1; 647 | 648 | scrollParent.scrollBy({ 649 | top: distFromAnchorToTargetY - deficientScroll, 650 | behavior: this.config.scrollBehavior.selected, 651 | }); 652 | 653 | // 부족한 스크롤은 조상 스크롤 컨테이너에게 전가. 654 | if (scrollParent !== document.scrollingElement) { 655 | this.scrollRecursively(scrollParent, deficientScroll); 656 | } 657 | } 658 | 659 | private scrollRecursively(element: HTMLElement, extraOffset: number): void { 660 | // 스크롤 컨테이너의 position이 fixed라면 스크롤 부모의 스크롤을 움직여도 위치가 변하지 않음. 661 | if (getComputedStyle(element).position == 'fixed') { 662 | return; 663 | } 664 | 665 | let scrollParent = element.parentElement; 666 | while (scrollParent) { 667 | if (this.isScrollable(scrollParent)) break; 668 | if (getComputedStyle(scrollParent).position == 'fixed') return; 669 | scrollParent = scrollParent.parentElement; 670 | } 671 | 672 | if (!scrollParent || scrollParent === document.body) { 673 | scrollParent = document.scrollingElement as HTMLElement; 674 | } 675 | 676 | if (!scrollParent) return; 677 | 678 | const maxScrollTop = scrollParent.scrollHeight - scrollParent.clientHeight; 679 | const availableIncreaseScrollAmount = maxScrollTop - scrollParent.scrollTop; 680 | const availableDecreaseScrollAmount = scrollParent.scrollTop; 681 | const deficientScroll = 682 | extraOffset > 0 683 | ? Math.max(0, extraOffset - availableIncreaseScrollAmount) 684 | : Math.max(0, Math.abs(extraOffset) - availableDecreaseScrollAmount) * -1; 685 | 686 | // console.debug(`deficientScroll=${deficientScroll}`); 687 | 688 | scrollParent.scrollBy({ 689 | top: extraOffset - deficientScroll, 690 | behavior: this.config.scrollBehavior.selected, 691 | }); 692 | 693 | // 부족한 스크롤은 또다시 조상 스크롤 컨테이너에게 전가. 694 | if (scrollParent !== document.scrollingElement) { 695 | this.scrollRecursively(scrollParent, deficientScroll); 696 | } 697 | } 698 | 699 | getSentenceRects(): Rect[] { 700 | const anchor = this.anchorMap.get(this.focusInfo.nodeIdx)![this.focusInfo.anchorIdx]; 701 | return this.getFloorSeperatedRectsFromAnchor(anchor); 702 | } 703 | 704 | getFirstCharRect(): Rect | null { 705 | const anchor = this.anchorMap.get(this.focusInfo.nodeIdx)![this.focusInfo.anchorIdx]; 706 | return this.getFirstCharRectFromAnchor(anchor); 707 | } 708 | 709 | scrollToFocusedAnchor(): void { 710 | if (this.config.autoScroll.selected != 'true') return; 711 | if (this.focusInfo.nodeIdx == -1) return; 712 | 713 | const focusedAnchor = this.anchorMap.get(this.focusInfo.nodeIdx)![this.focusInfo.anchorIdx]; 714 | 715 | this.scrollToAnchor(focusedAnchor, this.config.focusYBias.value / 100); 716 | } 717 | 718 | existsAnchorRects(): boolean { 719 | const anchor = this.anchorMap.get(this.focusInfo.nodeIdx)![this.focusInfo.anchorIdx]; 720 | const rects = this.getFloorSeperatedRectsFromAnchor(anchor); 721 | return rects.length > 0; 722 | } 723 | 724 | printInfo(node: Node): void { 725 | console.debug(` 726 | --- Node Info --- 727 | nodeIdx=${this.nodeIdxMap.get(node)} 728 | node.nodeType=${node.nodeType} 729 | node.nodeName=${node.nodeName} 730 | node.parentNode?.nodeType=${node.parentNode?.nodeType} 731 | node.parentNode?.nodeName=${node.parentNode?.nodeName} 732 | -------------`); 733 | } 734 | } 735 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | --------------------------------------------------------------------------------