Skip to content

Commit

Permalink
feat: use highlight api for chrome; use manifest v3
Browse files Browse the repository at this point in the history
  • Loading branch information
neaumusic committed Jun 17, 2023
1 parent 9338cdc commit ec44dbf
Show file tree
Hide file tree
Showing 33 changed files with 2,823 additions and 1,352 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,15 @@ Firefox Add-ons (packed, .xpi):
2. Click "Add to Firefox", click "Add"

GitHub (latest release):
1. Click [Releases](https://github.com/neaumusic/selection-highlighter/releases), download the latest 'chrome_extension.zip' or `firefox_extension.zip` asset, unzip the folder to a permanent location
1. Click [Releases](https://github.com/neaumusic/selection-highlighter/releases), download the latest `selection_highlighter_chrome_extension.zip` or `selection_highlighter_firefox_extension.zip` asset, unzip the folder to a permanent location
2. Add to browser
- Chrome: go to `chrome://extensions`, click "Load Unpacked", select the `chrome_extension` folder
- Firefox: go to `about:debugging#/runtime/this-firefox`, click "Load Temporary Add-on", select `firefox_extension/manifest.json`

GitHub (build source):
1. Click green button "Clone Or Download", "[Download Zip](https://github.com/neaumusic/selection-highlighter/archive/master.zip)", and unzip the folder to a permanent location
2. Run `yarn` in the root (see package.json scripts)
- On mac/linux use `brew install yarn` (or better yet use [volta](https://volta.sh/) which automatically switches node/yarn versions by changing directories) if you don't have it ([homebrew](https://brew.sh/) computer club is where Apple was founded). brew and `n` / `nvm` version managers aren't really compatible with volta but I recommend volta (11/2021)
- On mac/linux use `brew install yarn` if you don't have it ([homebrew](https://brew.sh/) computer club is where Apple was founded)

3. Install in browser
- Chrome: go to `chrome://extensions`, click "Load Unpacked", select the `dist/chrome_extension` folder
Expand Down
16 changes: 13 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "selection_highlighter",
"version": "2.43",
"version": "3.0.0",
"description": "browser highlighter for code analysis",
"scripts": {
"build": "rm -rf dist; webpack --config webpack.config.js",
Expand All @@ -19,15 +19,25 @@
"homepage": "https://github.com/neaumusic/selection-highlighter#readme",
"devDependencies": {
"@babel/core": "^7.10.2",
"@babel/preset-react": "^7.18.6",
"@babel/preset-typescript": "^7.18.6",
"@emotion/react": "^11.10.5",
"@emotion/styled": "^11.10.5",
"@types/chrome": "^0.0.208",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.11",
"babel-loader": "^8.1.0",
"clean-webpack-plugin": "^3.0.0",
"copy-webpack-plugin": "^6.0.2",
"css-loader": "^3.5.3",
"file-loader": "^6.0.0",
"html-loader": "^1.1.0",
"html-webpack-plugin": "^4.3.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"remove-files-webpack-plugin": "^1.4.2",
"typescript": "^4.9.4",
"webpack": "^4.43.0",
"webpack-cli": "^3.3.11"
}
},
"dependencies": {}
}
237 changes: 237 additions & 0 deletions src/chrome_extension/content_script/highlighter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
import {
initOptions,
isSelectionValid,
isWindowLocationValid,
areKeysPressed,
occurrenceRegex,
isAncestorNodeValid,
trimRegex,
highlightName,
areScrollMarkersEnabled,
scrollMarkersDebounce,
} from "../options/options";
import {
isSelectionWithAnchorAndFocusNodes,
SelectionWithAnchorAndFocusNodes,
} from "./types";
import {
addPressedKeysListeners,
addStyleElement,
addScrollMarkersCanvas,
} from "./utils";

let pressedKeys: string[] = [];
let scrollMarkersCanvasContext: CanvasRenderingContext2D;
(async function () {
await initOptions();
await addStyleElement();
scrollMarkersCanvasContext = await addScrollMarkersCanvas();
pressedKeys = addPressedKeysListeners();
document.addEventListener("selectstart", onSelectStart);
document.addEventListener("selectionchange", onSelectionChange);
})();
/** @ts-ignore this is a new API */
const highlights = new Highlight();
/** @ts-ignore this is a new API */
CSS.highlights.set(highlightName(), highlights);

let isNewSelection = false;
let lastSelectionString: string;
let latestRunNumber = 0;
let drawMarkersTimeout: number;
function onSelectStart() {
isNewSelection = true;
}
function onSelectionChange() {
const selectionString = window.getSelection() + "";
if (!isNewSelection) {
if (selectionString === lastSelectionString) {
return;
}
}
isNewSelection = false;
lastSelectionString = selectionString;
const runNumber = ++latestRunNumber;

if (!isWindowLocationValid(window.location)) return;
if (!areKeysPressed(pressedKeys)) return;

highlights.clear();
highlight(runNumber);

requestAnimationFrame(() => {
scrollMarkersCanvasContext.clearRect(
0,
0,
scrollMarkersCanvasContext.canvas.width,
scrollMarkersCanvasContext.canvas.height
);
});
clearTimeout(drawMarkersTimeout);
drawMarkersTimeout = window.setTimeout(() => {
drawScrollMarkers(runNumber);
}, scrollMarkersDebounce());
}

function highlight(runNumber: number) {
const selection = document.getSelection();
if (!isSelectionWithAnchorAndFocusNodes(selection)) return;

const trimmedSelection = String(selection).match(trimRegex());
if (!trimmedSelection) return;

const leadingSpaces = trimmedSelection[1];
const selectionString = trimmedSelection[2];
const trailingSpaces = trimmedSelection[3];
if (!isSelectionValid(selectionString, selection)) return;

// https://stackoverflow.com/questions/3561493/is-there-a-regexp-escape-function-in-javascript
const regex = occurrenceRegex(
selectionString.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&")
);

const treeWalker = document.createTreeWalker(
document.body,
NodeFilter.SHOW_TEXT,
null
);
let match;
while (treeWalker.nextNode() && runNumber === latestRunNumber) {
if (!(treeWalker.currentNode instanceof Text)) continue;
while ((match = regex.exec(treeWalker.currentNode.data))) {
highlightOccurrences(selection, treeWalker.currentNode, match);
}
}

function highlightOccurrences(
selection: SelectionWithAnchorAndFocusNodes,
textNode: Text,
match: RegExpExecArray
) {
if (!isAncestorNodeValid(textNode.parentNode)) return;

const matchIndex = match.index;
const anchorToFocusDirection = selection.anchorNode.compareDocumentPosition(
selection.focusNode
);

function isSelectionAcrossNodesLeftToRight() {
return anchorToFocusDirection & Node.DOCUMENT_POSITION_FOLLOWING;
}

function isSelectionAcrossNodesRightToLeft() {
return anchorToFocusDirection & Node.DOCUMENT_POSITION_PRECEDING;
}

function isUsersSelection() {
if (isSelectionAcrossNodesLeftToRight()) {
if (textNode === selection.anchorNode) {
return (
(selection.anchorNode.nodeType === Node.ELEMENT_NODE &&
selection.anchorOffset === 0) ||
selection.anchorOffset <= matchIndex - leadingSpaces.length
);
} else if (textNode === selection.focusNode) {
return (
(selection.focusNode.nodeType === Node.ELEMENT_NODE &&
selection.focusOffset === 0) ||
selection.focusOffset >=
matchIndex + selectionString.length + trailingSpaces.length
);
} else {
return (
selection.anchorNode.compareDocumentPosition(textNode) &
Node.DOCUMENT_POSITION_FOLLOWING &&
selection.focusNode.compareDocumentPosition(textNode) &
Node.DOCUMENT_POSITION_PRECEDING
);
}
} else if (isSelectionAcrossNodesRightToLeft()) {
if (textNode === selection.anchorNode) {
return (
(selection.anchorNode.nodeType === Node.ELEMENT_NODE &&
selection.anchorOffset === 0) ||
selection.anchorOffset >=
matchIndex + selectionString.length + trailingSpaces.length
);
} else if (textNode === selection.focusNode) {
return (
(selection.focusNode.nodeType === Node.ELEMENT_NODE &&
selection.focusOffset === 0) ||
selection.focusOffset <= matchIndex - leadingSpaces.length
);
} else {
return (
selection.anchorNode.compareDocumentPosition(textNode) &
Node.DOCUMENT_POSITION_PRECEDING &&
selection.focusNode.compareDocumentPosition(textNode) &
Node.DOCUMENT_POSITION_FOLLOWING
);
}
} else {
if (selection.anchorOffset < selection.focusOffset) {
return (
textNode === selection.anchorNode &&
selection.anchorOffset <= matchIndex - leadingSpaces.length &&
selection.focusOffset >=
matchIndex + selectionString.length + trailingSpaces.length
);
} else if (selection.anchorOffset > selection.focusOffset) {
return (
textNode === selection.focusNode &&
selection.focusOffset <= matchIndex - leadingSpaces.length &&
selection.anchorOffset >=
matchIndex + selectionString.length + trailingSpaces.length
);
}
}
}

if (!isUsersSelection()) {
const range = new Range();
range.selectNode(textNode);
range.setStart(textNode, matchIndex);
range.setEnd(textNode, matchIndex + selectionString.length);
highlights.add(range);
}
}
}

function drawScrollMarkers(runNumber: number) {
if (areScrollMarkersEnabled()) {
for (let highlightedNode of highlights) {
requestAnimationFrame(() => {
const dpr = devicePixelRatio || 1;
if (runNumber === latestRunNumber) {
const clientRect = highlightedNode.getBoundingClientRect();
if (!clientRect.width || !clientRect.height) return false;

// window height times percent of element position in document
const top =
(window.innerHeight *
(document.documentElement.scrollTop +
clientRect.top +
0.5 * (clientRect.top - clientRect.bottom))) /
document.documentElement.scrollHeight;

scrollMarkersCanvasContext.beginPath();
scrollMarkersCanvasContext.lineWidth = 1 * dpr;
scrollMarkersCanvasContext.strokeStyle = "grey";
scrollMarkersCanvasContext.fillStyle = "yellow";
scrollMarkersCanvasContext.strokeRect(
0.5 * dpr,
(top + 0.5) * dpr,
15 * dpr,
3 * dpr
);
scrollMarkersCanvasContext.fillRect(
1 * dpr,
(top + 1) * dpr,
14 * dpr,
2 * dpr
);
}
});
}
}
}
9 changes: 9 additions & 0 deletions src/chrome_extension/content_script/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export type SelectionWithAnchorAndFocusNodes = Selection & {
anchorNode: Node;
focusNode: Node;
};
export function isSelectionWithAnchorAndFocusNodes(
selection: Selection | null
): selection is SelectionWithAnchorAndFocusNodes {
return !!selection && !!selection.anchorNode && !!selection.focusNode;
}
70 changes: 70 additions & 0 deletions src/chrome_extension/content_script/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import {
highlightName,
highlightStyles,
scrollMarkersCanvasClassName,
} from "../options/options";

export async function addStyleElement() {
const style = document.createElement("style");
style.textContent = `
::highlight(${highlightName()}) {
${Object.entries(highlightStyles())
.map(([styleName, styleValue]) => `${styleName}: ${styleValue};`)
.join("\n ")}
}
.${scrollMarkersCanvasClassName()} {
pointer-events: none;
position: fixed;
z-index: 2147483647;
top: 0;
right: 0;
width: 16px;
height: 100vh;
}
`;
return new Promise<void>((resolve) => {
requestAnimationFrame(() => {
document.body.appendChild(style);
resolve();
});
});
}

export async function addScrollMarkersCanvas() {
const scrollMarkersCanvas = document.createElement("canvas");
scrollMarkersCanvas.className = scrollMarkersCanvasClassName();
scrollMarkersCanvas.width = 16 * devicePixelRatio || 1;
scrollMarkersCanvas.height = window.innerHeight * devicePixelRatio || 1;

window.addEventListener("resize", () => {
requestAnimationFrame(() => {
scrollMarkersCanvas.height = window.innerHeight * devicePixelRatio || 1;
});
});
return new Promise<CanvasRenderingContext2D>((resolve) => {
requestAnimationFrame(() => {
document.body.appendChild(scrollMarkersCanvas);
resolve(scrollMarkersCanvas.getContext("2d")!);
});
});
}

export function addPressedKeysListeners() {
const pressedKeys: KeyboardEvent["key"][] = [];
document.addEventListener("keydown", (e) => {
const index = pressedKeys.indexOf(e.key);
if (index === -1) {
pressedKeys.push(e.key);
}
});
document.addEventListener("keyup", (e) => {
const index = pressedKeys.indexOf(e.key);
if (index !== -1) {
pressedKeys.splice(index, 1);
}
});
window.addEventListener("blur", (e) => {
pressedKeys.splice(0, pressedKeys.length);
});
return pressedKeys;
}
Loading

0 comments on commit ec44dbf

Please sign in to comment.