Use the Chrome-extension's LintFramework for the web demo (#1893)
Some checks are pending
Build Chrome Plugin / package (push) Waiting to run
Build Binaries / Release harper-cli - macOS-aarch64 (push) Waiting to run
Build Binaries / Release harper-cli - Linux-aarch64-GNU (push) Waiting to run
Build Binaries / Release harper-cli - Linux-aarch64-musl (push) Waiting to run
Build Binaries / Release harper-cli - macOS-x86_64 (push) Waiting to run
Build Binaries / Release harper-cli - Linux-x86_64-GNU (push) Waiting to run
Build Binaries / Release harper-cli - Linux-x86_64-musl (push) Waiting to run
Build Binaries / Release harper-cli - Windows-x86_64 (push) Waiting to run
Build Binaries / Release harper-ls - macOS-aarch64 (push) Waiting to run
Build Binaries / Release harper-ls - Linux-aarch64-GNU (push) Waiting to run
Build Binaries / Release harper-ls - Linux-aarch64-musl (push) Waiting to run
Build Binaries / Release harper-ls - macOS-x86_64 (push) Waiting to run
Precommit / precommit (push) Waiting to run
Build Binaries / Release harper-ls - Linux-x86_64-GNU (push) Waiting to run
Build Binaries / Release harper-ls - Linux-x86_64-musl (push) Waiting to run
Build Binaries / Release harper-ls - Windows-x86_64 (push) Waiting to run
Build Web / build (push) Waiting to run
Package VS Code Plugin / Package - darwin-arm64 (push) Waiting to run
Package VS Code Plugin / Package - darwin-x64 (push) Waiting to run
Package VS Code Plugin / Package - linux-arm64 (push) Waiting to run
Package VS Code Plugin / Package - linux-x64 (push) Waiting to run
Package VS Code Plugin / Package - win32-x64 (push) Waiting to run
Package WordPress Plugin / package (push) Waiting to run

This commit is contained in:
Elijah Potter 2025-09-10 14:24:46 -06:00 committed by GitHub
parent 191f27f727
commit 353b8cd5bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 1042 additions and 952 deletions

View file

@ -30,8 +30,10 @@ WORKDIR /usr/build/packages/harper.js
RUN pnpm build && ./docs.sh
WORKDIR /usr/build/packages/web
WORKDIR /usr/build/packages/lint-framework
RUN pnpm build
WORKDIR /usr/build/packages/web
RUN pnpm build
FROM node:${NODE_VERSION}

View file

@ -24,6 +24,15 @@ build-harperjs: build-wasm
# Generate API reference
./docs.sh
# Build the browser lint framework module
build-lint-framework:
#!/usr/bin/env bash
set -eo pipefail
cd "{{justfile_directory()}}/packages/lint-framework"
pnpm install
pnpm build
test-harperjs: build-harperjs
#!/usr/bin/env bash
set -eo pipefail
@ -66,7 +75,7 @@ build-wp: build-harperjs
pnpm plugin-zip
# Compile the website's dependencies and start a development server. Note that if you make changes to `harper-wasm`, you will have to re-run this command.
dev-web: build-harperjs
dev-web: build-harperjs build-lint-framework
#!/usr/bin/env bash
set -eo pipefail
@ -75,7 +84,7 @@ dev-web: build-harperjs
pnpm dev
# Build the Harper website.
build-web: build-harperjs
build-web: build-harperjs build-lint-framework
#!/usr/bin/env bash
set -eo pipefail
@ -96,7 +105,7 @@ build-obsidian: build-harperjs
zip harper-obsidian-plugin.zip manifest.json main.js
# Build the Chrome extension.
build-chrome-plugin: build-harperjs
build-chrome-plugin: build-harperjs build-lint-framework
#!/usr/bin/env bash
set -eo pipefail
@ -106,7 +115,7 @@ build-chrome-plugin: build-harperjs
pnpm zip-for-chrome
# Start a development server for the Chrome extension.
dev-chrome-plugin: build-harperjs
dev-chrome-plugin: build-harperjs build-lint-framework
#!/usr/bin/env bash
set -eo pipefail
@ -116,7 +125,7 @@ dev-chrome-plugin: build-harperjs
pnpm dev
# Build the Firefox extension.
build-firefox-plugin: build-harperjs
build-firefox-plugin: build-harperjs build-lint-framework
#!/usr/bin/env bash
set -eo pipefail

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-book-down-icon lucide-book-down"><path d="M12 13V7"/><path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20"/><path d="m9 10 3 3 3-3"/></svg>

Before

Width:  |  Height:  |  Size: 385 B

View file

@ -32,7 +32,6 @@
"@types/jquery": "^3.5.32",
"@types/lodash-es": "^4.17.12",
"@types/node": "catalog:",
"@types/virtual-dom": "^2.1.4",
"flowbite": "^3.1.2",
"flowbite-svelte": "^0.44.18",
"flowbite-svelte-icons": "^2.1.1",
@ -53,10 +52,10 @@
"@tailwindcss/vite": "^4.1.4",
"@webcomponents/custom-elements": "^1.6.0",
"harper.js": "workspace:*",
"lint-framework": "workspace:*",
"jquery": "^3.7.1",
"lodash-es": "^4.17.21",
"lru-cache": "^11.1.0",
"tailwindcss": "^4.1.4",
"virtual-dom": "^2.1.1"
"tailwindcss": "^4.1.4"
}
}

View file

@ -1,7 +1,7 @@
import type { Dialect, LintConfig } from 'harper.js';
import type { UnpackedLint } from 'lint-framework';
import { LRUCache } from 'lru-cache';
import type { ActivationKey } from './protocol';
import type { UnpackedLint } from './unpackLint';
export default class ProtocolClient {
private static readonly lintCache = new LRUCache<string, Promise<any>>({

View file

@ -1,305 +0,0 @@
/** biome-ignore-all lint/complexity/useArrowFunction: It cannot be an arrow function for the logic to work. */
import h from 'virtual-dom/h';
import bookDownSvg from '../assets/book-down.svg?raw';
import type { IgnorableLintBox, LintBox } from './Box';
import lintKindColor from './lintKindColor';
import ProtocolClient from './ProtocolClient';
import type { UnpackedSuggestion } from './unpackLint';
var FocusHook = function () {};
FocusHook.prototype.hook = function (node, _propertyName, _previousValue) {
if ((node as any).__harperAutofocused) {
return;
}
requestAnimationFrame(() => {
node.focus();
Object.defineProperty(node, '__harperAutofocused', {
value: true,
enumerable: false,
configurable: false,
});
});
};
/** biome-ignore-all lint/complexity/useArrowFunction: It cannot be an arrow function for the logic to work. */
var CloseOnEscapeHook = function (onClose: () => void) {
this.onClose = onClose;
};
CloseOnEscapeHook.prototype.hook = function (this: { onClose: () => void }, node: HTMLElement) {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
this.onClose();
}
};
window.addEventListener('keydown', handler);
(node as any).__harperCloseOnEscapeHandler = handler;
};
CloseOnEscapeHook.prototype.unhook = function (this: any, node: HTMLElement) {
const handler = (node as any).__harperCloseOnEscapeHandler;
if (handler) {
window.removeEventListener('keydown', handler);
delete (node as any).__harperCloseOnEscapeHandler;
}
};
function header(title: string, color: string, onClose: () => void): any {
const closeButton = h(
'button',
{
className: 'harper-close-btn',
onclick: onClose,
title: 'Close',
'aria-label': 'Close',
},
'×',
);
const settingsButton = h(
'button',
{
className: 'harper-gear-btn',
onclick: () => {
ProtocolClient.openOptions();
},
title: 'Settings',
'aria-label': 'Settings',
},
'⚙',
);
const controls = h('div', { className: 'harper-controls' }, [settingsButton, closeButton]);
const titleEl = h('span', {}, title);
return h(
'div',
{
className: 'harper-header',
style: { borderBottom: `2px solid ${color}` },
},
[titleEl, controls],
);
}
function body(message_html: string): any {
return h('div', { className: 'harper-body', innerHTML: message_html }, []);
}
function button(
label: string,
extraStyle: { [key: string]: string },
onClick: (event: Event) => void,
description?: string,
extraProps: Record<string, unknown> = {},
): any {
const desc = description || label;
return h(
'button',
{
className: 'harper-btn',
style: extraStyle,
onclick: onClick,
title: desc,
'aria-label': desc,
...extraProps,
},
label,
);
}
function footer(leftChildren: any, rightChildren: any) {
const left = h('div', { className: 'harper-child-cont' }, leftChildren);
const right = h('div', { className: 'harper-child-cont' }, rightChildren);
return h('div', { className: 'harper-footer' }, [left, right]);
}
function addToDictionary(box: LintBox): any {
return h(
'button',
{
className: 'harper-btn',
onclick: () => {
ProtocolClient.addToUserDictionary([box.lint.problem_text]);
},
title: 'Add word to user dictionary',
'aria-label': 'Add word to user dictionary',
innerHTML: bookDownSvg,
},
[],
);
}
function suggestions(
suggestions: UnpackedSuggestion[],
apply: (s: UnpackedSuggestion) => void,
): any {
return suggestions.map((s: UnpackedSuggestion, i: number) => {
const label = s.replacement_text !== '' ? s.replacement_text : s.kind;
const desc = `Replace with "${label}"`;
const props = i === 0 ? { hook: new FocusHook() } : {};
return button(label, { background: '#2DA44E', color: '#FFFFFF' }, () => apply(s), desc, props);
});
}
function styleTag() {
return h('style', { id: 'harper-suggestion-style' }, [
`code{
background-color:#e3eccf;
padding:0.125rem;
border-radius:0.25rem
}
.harper-container{
max-width:420px;
max-height:400px;
overflow-y:auto;
background:#ffffff;
border:1px solid #d0d7de;
border-radius:8px;
box-shadow:0 4px 12px rgba(140,149,159,0.3);
padding:8px;
display:flex;
flex-direction:column;
z-index:5000;
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;
pointer-events:auto
}
.harper-header{
display:flex;
align-items:center;
justify-content:space-between;
font-weight:600;
font-size:14px;
line-height:20px;
color:#1f2328;
padding-bottom:4px;
margin-bottom:4px;
user-select:none
}
.harper-body{
font-size:14px;
line-height:20px;
color:#57606a
}
.harper-btn{
display:inline-flex;
align-items:center;
justify-content:center;
gap:4px;
cursor:pointer;
border:none;
border-radius:6px;
padding:3px 6px;
min-height:28px;
font-size:13px;
font-weight:600;
line-height:20px;
transition:background 120ms ease,transform 80ms ease
}
.harper-btn:hover{filter:brightness(0.92)}
.harper-btn:active{transform:scale(0.97)}
.harper-close-btn{background:transparent;border:none;cursor:pointer;font-size:20px;line-height:1;color:#57606a;padding:0 4px;}
.harper-close-btn:hover{color:#1f2328;}
.harper-gear-btn{background:transparent;border:none;cursor:pointer;font-size:22px;line-height:1;color:#57606a;padding:0 4px;}
.harper-gear-btn:hover{color:#1f2328;}
.harper-controls{display:flex;align-items:center;gap:6px;}
.harper-child-cont{
display:flex;
flex-wrap:wrap;
justify-content:flex-end;
gap:8px
}
.harper-footer{
display:flex;
flex-wrap:wrap;
justify-content:space-between;
padding:2px;
gap:16px
}
.fade-in {
animation: fadeIn 100ms ease-in-out forwards;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@media (prefers-color-scheme:dark){
code{background-color:#1f2d3d;color:#c9d1d9}
.harper-container{
background:#0d1117;
border-color:#30363d;
box-shadow:0 4px 12px rgba(1,4,9,0.85)
}
.harper-header{color:#e6edf3}
.harper-body{color:#8b949e}
.harper-btn{
background:#21262d;
color:#c9d1d9
}
.harper-btn:hover{filter:brightness(1.15)}
.harper-close-btn{color:#8b949e;}
.harper-close-btn:hover{color:#e6edf3;}
.harper-gear-btn{color:#8b949e;}
.harper-gear-btn:hover{color:#e6edf3;}
.harper-btn[style*="background: #2DA44E"]{background:#238636}
.harper-btn[style*="background: #e5e5e5"]{
background:#4b4b4b;
color:#ffffff
}
}`,
]);
}
function ignoreLint(onIgnore: () => void): any {
return button(
'Ignore',
{ background: '#e5e5e5', color: '#000000', fontWeight: 'lighter' },
onIgnore,
'Ignore this lint',
);
}
export default function SuggestionBox(box: IgnorableLintBox, close: () => void) {
const top = box.y + box.height + 3;
let bottom: number | undefined;
const left = box.x;
if (top + 400 > window.innerHeight) {
bottom = window.innerHeight - box.y - 3;
}
const positionStyle: { [key: string]: string } = {
position: 'fixed',
top: bottom ? '' : `${top}px`,
bottom: bottom ? `${bottom}px` : '',
left: `${left}px`,
};
return h(
'div',
{
className: 'harper-container fade-in',
style: positionStyle,
'harper-close-on-escape': new CloseOnEscapeHook(close),
},
[
styleTag(),
header(box.lint.lint_kind_pretty, lintKindColor(box.lint.lint_kind), close),
body(box.lint.message_html),
footer(
suggestions(box.lint.suggestions, (v) => {
box.applySuggestion(v);
close();
}),
[
box.lint.lint_kind === 'Spelling' ? addToDictionary(box) : undefined,
ignoreLint(box.ignoreLint),
],
),
],
);
}

View file

@ -1,4 +1,5 @@
import { BinaryModule, Dialect, type LintConfig, LocalLinter } from 'harper.js';
import { unpackLint } from 'lint-framework';
import {
ActivationKey,
type AddToUserDictionaryRequest,
@ -29,7 +30,6 @@ import {
type SetUserDictionaryRequest,
type UnitResponse,
} from '../protocol';
import unpackLint from '../unpackLint';
console.log('background is running');

View file

@ -1,10 +1,14 @@
import '@webcomponents/custom-elements';
import $ from 'jquery';
import { isVisible, leafNodes } from '../domUtils';
import LintFramework from '../LintFramework';
import { isVisible, LintFramework, leafNodes } from 'lint-framework';
import ProtocolClient from '../ProtocolClient';
const fw = new LintFramework();
const fw = new LintFramework((text, domain) => ProtocolClient.lint(text, domain), {
ignoreLint: (hash) => ProtocolClient.ignoreHash(hash),
getActivationKey: () => ProtocolClient.getActivationKey(),
openOptions: () => ProtocolClient.openOptions(),
addToUserDictionary: (words) => ProtocolClient.addToUserDictionary(words),
});
const keepAliveCallback = () => {
ProtocolClient.lint('', 'example.com');

View file

@ -1,166 +0,0 @@
import { type Box, domRectToBox } from './Box';
import TextFieldRange from './TextFieldRange';
export function findAncestor(
el: HTMLElement,
predicate: (el: HTMLElement) => boolean,
): HTMLElement | null {
let node = el.parentElement;
while (node != null) {
if (predicate(node)) {
return node;
}
node = node.parentElement;
}
return null;
}
export function findChild(
el: HTMLElement,
predicate: (el: HTMLElement) => boolean,
): HTMLElement | null {
const queue: HTMLElement[] = Array.from(el.children) as HTMLElement[];
while (queue.length > 0) {
const node = queue.shift() as HTMLElement;
if (predicate(node)) {
return node;
}
queue.push(...(Array.from(node.children) as HTMLElement[]));
}
return null;
}
/** Determines if a given node is a child of a P2 editor instance.
* If so, returns the root node of that instance. */
export function getP2Root(el: HTMLElement): HTMLElement | null {
return findAncestor(el, (node: HTMLElement) => node.classList.contains('p2-editor'));
}
/** Determines if a given node is a child of a Gutenberg editor instance.
* If so, returns the root node of that instance. */
export function getGutenbergRoot(el: HTMLElement): HTMLElement | null {
return findAncestor(el, (node: HTMLElement) =>
node.classList.contains('block-editor-block-canvas'),
);
}
/** Determines if a given node is a child of a Lexical editor instance.
* If so, returns the root node of that instance. */
export function getLexicalRoot(el: HTMLElement): HTMLElement | null {
return findAncestor(
el,
(node: HTMLElement) => node.getAttribute('data-lexical-editor') == 'true',
);
}
export function getLexicalEditable(el: HTMLElement): HTMLElement | null {
const lexical = getLexicalRoot(el);
if (lexical == null) {
return null;
}
return findChild(lexical, (node: HTMLElement) => node.getAttribute('contenteditable') == 'true');
}
/** Determines if a given node is a child of a Ghost editor instance.
* If so, returns the root node of that instance. */
export function getGhostRoot(el: HTMLElement): HTMLElement | null {
return findAncestor(el, (node: HTMLElement) => node.classList.contains('gh-editor'));
}
/** Determines if a given node is a child of a Slate.js editor instance.
* If so, returns the root node of that instance. */
export function getSlateRoot(el: HTMLElement): HTMLElement | null {
return findAncestor(el, (node: HTMLElement) => node.getAttribute('data-slate-editor') == 'true');
}
/** Determines if a given node is a child of a Draft.js editor instance.
* If so, returns the root node of that instance. */
export function getDraftRoot(el: HTMLElement): HTMLElement | null {
return findAncestor(el, (node: HTMLElement) => node.classList.contains('DraftEditor-root'));
}
/** Determines if a given node is a child of a Trix editor instance.
* If so, returns the root node of that instance. */
export function getTrixRoot(el: HTMLElement): HTMLElement | null {
return findAncestor(el, (node: HTMLElement) => node.nodeName == 'TRIX-EDITOR');
}
/** Determines if a given node is a child of a Reddit composer instance.
* If so, returns the root node of that instance. */
export function getShredditComposerRoot(el: HTMLElement): HTMLElement | null {
return findAncestor(el, (node: HTMLElement) => node.nodeName == 'SHREDDIT-COMPOSER');
}
/** Determines if a given node is a child of a Quill.js editor instance.
* If so, returns the root node of that instance. */
export function getQuillJsRoot(el: HTMLElement): HTMLElement | null {
return findAncestor(el, (node: HTMLElement) => node.classList.contains('ql-container'));
}
/** Determines if a given node is a child of a Medium.com editor instance.
* If so, returns the root node of that instance. */
export function getMediumRoot(el: HTMLElement): HTMLElement | null {
return findAncestor(
el,
(node: HTMLElement) => node.nodeName == 'MAIN' && location.hostname == 'medium.com',
);
}
/** Determines if a given node is a child of a Notion editor instance.
* If so, returns the root node of that instance. */
export function getNotionRoot(el: HTMLElement): HTMLElement | null {
return document.getElementById('notion-app');
}
/** Determines if a given node is a child of a CodeMirror editor instance.
* If so, returns the root node of that instance. */
export function getCMRoot(el: HTMLElement): HTMLElement | null {
return findAncestor(el, (node: HTMLElement) => node.classList.contains('cm-editor'));
}
/** Determines if a given node is a child of a ProseMirror editor instance.
* If so, returns the root node of that instance. */
export function getPMRoot(el: HTMLElement): HTMLElement | null {
return findAncestor(el, (node: HTMLElement) => node.classList.contains('ProseMirror'));
}
export function getCaretPosition(): Box | null {
const active = document.activeElement;
if (
active instanceof HTMLTextAreaElement ||
(active instanceof HTMLInputElement && active.type === 'text')
) {
if (
active.selectionStart == null ||
active.selectionEnd == null ||
active.selectionStart !== active.selectionEnd
) {
return null;
}
const offset = active.selectionStart;
const tfRange = new TextFieldRange(active, offset, offset);
const rects = tfRange.getClientRects();
tfRange.detach();
return rects.length ? domRectToBox(rects[0]) : null;
}
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return null;
const range = selection.getRangeAt(0);
if (!range.collapsed) return null;
return domRectToBox(range.getBoundingClientRect());
}

View file

@ -1,5 +1,5 @@
import type { Dialect, LintConfig, Summary } from 'harper.js';
import type { UnpackedLint, UnpackedSuggestion } from './unpackLint';
import type { UnpackedLint, UnpackedSuggestion } from 'lint-framework';
export type Request =
| LintRequest

View file

@ -1,5 +1,5 @@
import { expect, test } from '@playwright/test';
import lintKindColor, { LINT_KINDS } from '../src/lintKindColor';
import { LINT_KINDS, lintKindColor } from 'lint-framework';
test('display lint kind colors', async ({ page }, testInfo) => {
// Generate color boxes for each lint kind
@ -14,13 +14,13 @@ test('display lint kind colors', async ({ page }, testInfo) => {
<head>
<title>Lint Kind Colors</title>
<style>
body {
font-family: Arial, sans-serif;
body {
font-family: Arial, sans-serif;
padding: 20px;
background: #f5f5f5;
}
h1 {
color: #333;
h1 {
color: #333;
margin-top: 0;
}
.container {

View file

@ -1,5 +1,5 @@
import type { Locator, Page } from '@playwright/test';
import type { Box } from '../src/Box';
import type { Box } from 'lint-framework';
import { expect, test } from './fixtures';
export function randomString(length: number): string {

1
packages/lint-framework/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
dist

View file

@ -0,0 +1,5 @@
# `lint-framework`
The `lint-framework` serves one specific purpose.
It contains all the logic needed to read and write text to a text editor on a web page to perform linting actions, as well as all logic needed to render underlines and UI for reviewing those actions.
It exists separate from the Chrome/Firefox extensions because there are places where we wish to perform linting actions outside of the Chrome extension (for example, in the demo on the Harper website).

View file

@ -0,0 +1,36 @@
{
"name": "lint-framework",
"version": "0.0.1",
"license": "Apache-2.0",
"type": "module",
"private": false,
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc && vite build",
"dev": "vite",
"test": "echo 'no tests'"
},
"dependencies": {
"virtual-dom": "^2.1.1"
},
"peerDependencies": {
"harper.js": "workspace:*"
},
"devDependencies": {
"@types/virtual-dom": "^2.1.4",
"type-fest": "^4.37.0",
"typescript": "catalog:",
"vite": "^6.1.0",
"vite-plugin-dts": "^4.5.0"
}
}

View file

@ -0,0 +1 @@
export default `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-book-down-icon lucide-book-down"><path d="M12 13V7"/><path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20"/><path d="m9 10 3 3 3-3"/></svg>`;

View file

@ -0,0 +1,12 @@
export * from './lint/Box';
export { default as computeLintBoxes } from './lint/computeLintBoxes';
export * from './lint/domUtils';
export * from './lint/editorUtils';
export { default as Highlights } from './lint/Highlights';
export { default as LintFramework } from './lint/LintFramework';
export * from './lint/lintKindColor';
export { default as lintKindColor } from './lint/lintKindColor';
export { default as PopupHandler } from './lint/PopupHandler';
export { default as RenderBox } from './lint/RenderBox';
export * from './lint/unpackLint';
export { default as unpackLint } from './lint/unpackLint';

View file

@ -1,3 +1,4 @@
import type SourceElement from './SourceElement';
import type { UnpackedLint, UnpackedSuggestion } from './unpackLint';
export type Box = {
@ -13,12 +14,12 @@ export type Box = {
export type LintBox = Box & {
lint: UnpackedLint;
source: HTMLElement;
source: SourceElement;
applySuggestion: (sug: UnpackedSuggestion) => void;
};
export type IgnorableLintBox = LintBox & {
ignoreLint: () => Promise<void>;
ignoreLint?: () => Promise<void>;
};
/** Get a box that represents the screen. */

View file

@ -18,10 +18,11 @@ import {
} from './editorUtils';
import lintKindColor from './lintKindColor';
import RenderBox from './RenderBox';
import type SourceElement from './SourceElement';
/** A class that renders highlights to a page and nothing else. Uses a virtual DOM to minimize jitter. */
export default class Highlights {
renderBoxes: Map<HTMLElement, RenderBox>;
renderBoxes: Map<SourceElement, RenderBox>;
constructor() {
this.renderBoxes = new Map();
@ -29,7 +30,7 @@ export default class Highlights {
public renderLintBoxes(boxes: LintBox[]) {
// Sort the lint boxes based on their source, so we can render them all together.
const sourceToBoxes: Map<HTMLElement, { boxes: LintBox[]; cpa: DOMRect | null }> = new Map();
const sourceToBoxes: Map<SourceElement, { boxes: LintBox[]; cpa: DOMRect | null }> = new Map();
for (const box of boxes) {
let renderBox = this.renderBoxes.get(box.source);
@ -138,17 +139,17 @@ export default class Highlights {
/** Determines which target the render boxes should be attached to.
* Depends on text editor. */
private computeRenderTarget(el: HTMLElement): HTMLElement {
private computeRenderTarget(el: SourceElement): HTMLElement {
if (el.parentElement?.classList.contains('ProseMirror')) {
return el.parentElement.parentElement;
return el.parentElement.parentElement!;
}
const queries = [
getNotionRoot,
getGhostRoot,
getDraftRoot,
getPMRoot,
getCMRoot,
getNotionRoot,
getSlateRoot,
getMediumRoot,
getShredditComposerRoot,
@ -162,11 +163,11 @@ export default class Highlights {
for (const query of queries) {
const root = query(el);
if (root != null) {
return root.parentElement;
return root.parentElement!;
}
}
return el.parentElement;
return el.parentElement!;
}
}
@ -190,11 +191,8 @@ function getInitialContainingRect(el: HTMLElement): DOMRect | null {
* content-visibility.
*
* Logs the element and the precise reason it qualifies.
*
* @param {Element} el
* @returns {boolean}
*/
function isContainingBlock(el): boolean {
function isContainingBlock(el: Element): boolean {
if (!(el instanceof Element)) {
throw new TypeError('Expected a DOM Element');
}

View file

@ -1,9 +1,10 @@
import type { Lint } from 'harper.js';
import computeLintBoxes from './computeLintBoxes';
import { isVisible } from './domUtils';
import Highlights from './Highlights';
import PopupHandler from './PopupHandler';
import ProtocolClient from './ProtocolClient';
import type { UnpackedLint } from './unpackLint';
type ActivationKey = 'off' | 'shift' | 'control';
/** Events on an input (any kind) that can trigger a re-render. */
const INPUT_EVENTS = ['focus', 'keyup', 'paste', 'change', 'scroll'];
@ -18,14 +19,38 @@ export default class LintFramework {
private scrollableAncestors: Set<HTMLElement>;
private lintRequested = false;
private renderRequested = false;
private lastLints: { target: HTMLElement; lints: Lint[] }[] = [];
private lastLints: { target: HTMLElement; lints: UnpackedLint[] }[] = [];
/** The function to be called to re-render the highlights. This is a variable because it is used to register/deregister event listeners. */
private updateEventCallback: () => void;
constructor() {
/** Function used to fetch lints for a given text/domain. */
private lintProvider: (text: string, domain: string) => Promise<UnpackedLint[]>;
/** Actions wired by host environment (extension/app). */
private actions: {
ignoreLint?: (hash: string) => Promise<void>;
getActivationKey?: () => Promise<ActivationKey>;
openOptions?: () => Promise<void>;
addToUserDictionary?: (words: string[]) => Promise<void>;
};
constructor(
lintProvider: (text: string, domain: string) => Promise<UnpackedLint[]>,
actions: {
ignoreLint?: (hash: string) => Promise<void>;
getActivationKey?: () => Promise<ActivationKey>;
openOptions?: () => Promise<void>;
addToUserDictionary?: (words: string[]) => Promise<void>;
},
) {
this.lintProvider = lintProvider;
this.actions = actions;
this.highlights = new Highlights();
this.popupHandler = new PopupHandler();
this.popupHandler = new PopupHandler({
getActivationKey: actions.getActivationKey,
openOptions: actions.openOptions,
addToUserDictionary: actions.addToUserDictionary,
});
this.targets = new Set();
this.scrollableAncestors = new Set();
this.lastLints = [];
@ -47,7 +72,7 @@ export default class LintFramework {
/** Returns the currents targets that are visible on-screen. */
onScreenTargets(): Node[] {
const onScreen = [];
const onScreen = [] as Node[];
for (const target of this.targets) {
if (isVisible(target)) {
@ -75,7 +100,7 @@ export default class LintFramework {
this.onScreenTargets().map(async (target) => {
if (!document.contains(target)) {
this.targets.delete(target);
return { target: null as HTMLElement | null, lints: [] };
return { target: null as HTMLElement | null, lints: [] as UnpackedLint[] };
}
const text =
@ -84,15 +109,15 @@ export default class LintFramework {
: target.textContent;
if (!text || text.length > 120000) {
return { target: null as HTMLElement | null, lints: [] };
return { target: null as HTMLElement | null, lints: [] as UnpackedLint[] };
}
const lints = await ProtocolClient.lint(text, window.location.hostname);
const lints = await this.lintProvider(text, window.location.hostname);
return { target: target as HTMLElement, lints };
}),
);
this.lastLints = lintResults;
this.lastLints = lintResults.filter((r) => r.target != null) as any;
this.lintRequested = false;
this.requestRender();
}
@ -123,18 +148,21 @@ export default class LintFramework {
const observer = new MutationObserver(this.updateEventCallback);
const config = { subtree: true, characterData: true };
if (target.tagName == undefined) {
observer.observe(target.parentElement!, config);
if ((target as any).tagName == undefined) {
observer.observe((target as any).parentElement!, config);
} else {
observer.observe(target, config);
observer.observe(target as Element, config);
}
const scrollableAncestors = getScrollableAncestors(target);
for (const el of scrollableAncestors) {
if (!this.scrollableAncestors.has(el)) {
this.scrollableAncestors.add(el);
el.addEventListener('scroll', this.updateEventCallback, { capture: true, passive: true });
if (!this.scrollableAncestors.has(el as HTMLElement)) {
this.scrollableAncestors.add(el as HTMLElement);
(el as HTMLElement).addEventListener('scroll', this.updateEventCallback, {
capture: true,
passive: true,
});
}
}
}
@ -151,12 +179,6 @@ export default class LintFramework {
}
}
private detachWindowListeners() {
for (const event of PAGE_EVENTS) {
window.removeEventListener(event, this.updateEventCallback);
}
}
private requestRender() {
if (this.renderRequested) {
return;
@ -166,7 +188,11 @@ export default class LintFramework {
requestAnimationFrame(() => {
const boxes = this.lastLints.flatMap(({ target, lints }) =>
target ? lints.flatMap((l) => computeLintBoxes(target, l)) : [],
target
? lints.flatMap((l) =>
computeLintBoxes(target, l as any, { ignoreLint: this.actions.ignoreLint }),
)
: [],
);
this.highlights.renderLintBoxes(boxes);
this.popupHandler.updateLintBoxes(boxes);
@ -183,7 +209,7 @@ export default class LintFramework {
function getScrollableAncestors(element: Node): Element[] {
const scrollables: Element[] = [];
const root = document.scrollingElement || document.documentElement;
let parent = element.parentElement;
let parent = (element as any).parentElement;
while (parent) {
const style = window.getComputedStyle(parent);

View file

@ -1,8 +1,9 @@
import h from 'virtual-dom/h';
import { closestBox, isPointInBox, type LintBox } from './Box';
import { closestBox, type IgnorableLintBox, isPointInBox } from './Box';
import { getCaretPosition } from './editorUtils';
import ProtocolClient from './ProtocolClient';
import { ActivationKey } from './protocol';
type ActivationKey = 'off' | 'shift' | 'control';
import RenderBox from './RenderBox';
import SuggestionBox from './SuggestionBox';
@ -26,13 +27,23 @@ function monitorActivationKey(
}
export default class PopupHandler {
private currentLintBoxes: LintBox[];
private currentLintBoxes: IgnorableLintBox[];
private popupLint: number | undefined;
private renderBox: RenderBox;
private pointerDownCallback: (e: PointerEvent) => void;
private activationKeyListener: (() => void) | undefined;
private readonly actions: {
getActivationKey?: () => Promise<ActivationKey>;
openOptions?: () => Promise<void>;
addToUserDictionary?: (words: string[]) => Promise<void>;
};
constructor() {
constructor(actions: {
getActivationKey?: () => Promise<ActivationKey>;
openOptions?: () => Promise<void>;
addToUserDictionary?: (words: string[]) => Promise<void>;
}) {
this.actions = actions;
this.currentLintBoxes = [];
this.renderBox = new RenderBox(document.body);
this.renderBox.getShadowHost().popover = 'manual';
@ -43,24 +54,22 @@ export default class PopupHandler {
};
this.updateActivationKeyListener();
chrome.storage.onChanged.addListener((changes) => {
if (changes.activationKey) {
this.updateActivationKeyListener();
}
});
}
private updateActivationKeyListener() {
if (this.activationKeyListener) {
this.activationKeyListener();
this.activationKeyListener = undefined;
}
ProtocolClient.getActivationKey().then((key) => {
if (key !== ActivationKey.Off) {
this.activationKeyListener = monitorActivationKey(() => this.openClosestToCaret(), key);
}
});
const getKey = this.actions.getActivationKey;
if (getKey) {
getKey().then((key) => {
if (key !== 'off') {
this.activationKeyListener = monitorActivationKey(() => this.openClosestToCaret(), key);
}
});
}
}
/** Tries to get the current caret position.
@ -97,7 +106,7 @@ export default class PopupHandler {
if (this.popupLint != null && this.popupLint < this.currentLintBoxes.length) {
const box = this.currentLintBoxes[this.popupLint];
tree = SuggestionBox(box, () => {
tree = SuggestionBox(box, this.actions, () => {
this.popupLint = undefined;
});
this.renderBox.getShadowHost().showPopover();
@ -108,19 +117,19 @@ export default class PopupHandler {
this.renderBox.render(tree);
}
public updateLintBoxes(boxes: LintBox[]) {
this.currentLintBoxes.forEach((b) =>
b.source.removeEventListener('pointerdown', this.pointerDownCallback),
);
public updateLintBoxes(boxes: IgnorableLintBox[]) {
this.currentLintBoxes.forEach((b) => {
b.source.removeEventListener('pointerdown', this.pointerDownCallback as EventListener);
});
if (boxes.length != this.currentLintBoxes.length) {
this.popupLint = undefined;
}
this.currentLintBoxes = boxes;
this.currentLintBoxes.forEach((b) =>
b.source.addEventListener('pointerdown', this.pointerDownCallback),
);
this.currentLintBoxes.forEach((b) => {
b.source.addEventListener('pointerdown', this.pointerDownCallback as EventListener);
});
this.render();
}

View file

@ -1,7 +1,6 @@
import type { VNode } from 'virtual-dom';
import createElement from 'virtual-dom/create-element';
import diff from 'virtual-dom/diff';
import h from 'virtual-dom/h';
import patch from 'virtual-dom/patch';
/** Wraps `virtual-dom` to create a box that is unaffected by the style of the rest of the page. */

View file

@ -0,0 +1,4 @@
/** An element that could be considered a _source_ for text to be linted.*/
type SourceElement = HTMLElement | Text;
export default SourceElement;

View file

@ -0,0 +1,330 @@
/** biome-ignore-all lint/complexity/useArrowFunction: It cannot be an arrow function for the logic to work. */
import h from 'virtual-dom/h';
import bookDownSvg from '../assets/bookDownSvg';
import type { IgnorableLintBox, LintBox } from './Box';
import lintKindColor from './lintKindColor';
// Decoupled: actions passed in by framework consumer
import type { UnpackedSuggestion } from './unpackLint';
var FocusHook: any = function () {};
FocusHook.prototype.hook = function (node: any, _propertyName: any, _previousValue: any) {
if ((node as any).__harperAutofocused) {
return;
}
requestAnimationFrame(() => {
node.focus();
Object.defineProperty(node, '__harperAutofocused', {
value: true,
enumerable: false,
configurable: false,
});
});
};
/** biome-ignore-all lint/complexity/useArrowFunction: It cannot be an arrow function for the logic to work. */
var CloseOnEscapeHook: any = function (this: any, onClose: () => void) {
this.onClose = onClose;
};
CloseOnEscapeHook.prototype.hook = function (this: { onClose: () => void }, node: HTMLElement) {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
this.onClose();
}
};
window.addEventListener('keydown', handler);
(node as any).__harperCloseOnEscapeHandler = handler;
};
CloseOnEscapeHook.prototype.unhook = function (this: any, node: HTMLElement) {
const handler = (node as any).__harperCloseOnEscapeHandler;
if (handler) {
window.removeEventListener('keydown', handler);
delete (node as any).__harperCloseOnEscapeHandler;
}
};
function header(
title: string,
color: string,
onClose: () => void,
openOptions?: () => Promise<void>,
): any {
const closeButton = h(
'button',
{
className: 'harper-close-btn',
onclick: onClose,
title: 'Close',
'aria-label': 'Close',
},
'×',
);
const settingsButton = openOptions
? h(
'button',
{
className: 'harper-gear-btn',
onclick: () => {
openOptions();
},
title: 'Settings',
'aria-label': 'Settings',
},
'⚙',
)
: undefined;
const controlsChildren = settingsButton ? [settingsButton, closeButton] : [closeButton];
const controls = h('div', { className: 'harper-controls' }, controlsChildren);
const titleEl = h('span', {}, title);
return h(
'div',
{
className: 'harper-header',
style: { borderBottom: `2px solid ${color}` },
},
[titleEl, controls],
);
}
function body(message_html: string): any {
return h('div', { className: 'harper-body', innerHTML: message_html }, []);
}
function button(
label: string,
extraStyle: { [key: string]: string },
onClick: (event: Event) => void,
description?: string,
extraProps: Record<string, unknown> = {},
): any {
const desc = description || label;
return h(
'button',
{
className: 'harper-btn',
style: extraStyle,
onclick: onClick,
title: desc,
'aria-label': desc,
...extraProps,
},
label,
);
}
function footer(leftChildren: any, rightChildren: any) {
const left = h('div', { className: 'harper-child-cont' }, leftChildren);
const right = h('div', { className: 'harper-child-cont' }, rightChildren);
return h('div', { className: 'harper-footer' }, [left, right]);
}
function addToDictionary(
box: LintBox,
addToUserDictionary?: (words: string[]) => Promise<void>,
): any {
return h(
'button',
{
className: 'harper-btn',
onclick: () => {
addToUserDictionary?.([box.lint.problem_text]);
},
title: 'Add word to user dictionary',
'aria-label': 'Add word to user dictionary',
innerHTML: bookDownSvg,
},
[],
);
}
function suggestions(
suggestions: UnpackedSuggestion[],
apply: (s: UnpackedSuggestion) => void,
): any {
return suggestions.map((s: UnpackedSuggestion, i: number) => {
const label = s.replacement_text !== '' ? s.replacement_text : String(s.kind);
const desc = `Replace with \"${label}\"`;
const props = i === 0 ? { hook: new FocusHook() } : {};
return button(label, { background: '#2DA44E', color: '#FFFFFF' }, () => apply(s), desc, props);
});
}
function styleTag() {
return h('style', { id: 'harper-suggestion-style' }, [
`code{
background-color:#e3eccf;
padding:0.125rem;
border-radius:0.25rem
}
.harper-container{
max-width:420px;
max-height:400px;
overflow-y:auto;
background:#ffffff;
border:1px solid #d0d7de;
border-radius:8px;
box-shadow:0 4px 12px rgba(140,149,159,0.3);
padding:8px;
display:flex;
flex-direction:column;
z-index:5000;
font-family:-apple-system,BlinkMacSystemFont,\"Segoe UI\",Helvetica,Arial,sans-serif;
pointer-events:auto
}
.harper-header{
display:flex;
align-items:center;
justify-content:space-between;
font-weight:600;
font-size:14px;
line-height:20px;
color:#1f2328;
padding-bottom:4px;
margin-bottom:4px;
user-select:none
}
.harper-body{
font-size:14px;
line-height:20px;
color:#57606a
}
.harper-btn{
display:inline-flex;
align-items:center;
justify-content:center;
gap:4px;
cursor:pointer;
border:none;
border-radius:6px;
padding:3px 6px;
min-height:28px;
font-size:13px;
font-weight:600;
line-height:20px;
transition:background 120ms ease,transform 80ms ease
}
.harper-btn:hover{filter:brightness(0.92)}
.harper-btn:active{transform:scale(0.97)}
.harper-close-btn{background:transparent;border:none;cursor:pointer;font-size:20px;line-height:1;color:#57606a;padding:0 4px;}
.harper-close-btn:hover{color:#1f2328;}
.harper-gear-btn{background:transparent;border:none;cursor:pointer;font-size:22px;line-height:1;color:#57606a;padding:0 4px;}
.harper-gear-btn:hover{color:#1f2328;}
.harper-controls{display:flex;align-items:center;gap:6px;}
.harper-child-cont{
display:flex;
flex-wrap:wrap;
justify-content:flex-end;
gap:8px
}
.harper-footer{
display:flex;
flex-wrap:wrap;
justify-content:space-between;
padding:2px;
gap:16px
}
.fade-in {
animation: fadeIn 100ms ease-in-out forwards;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@media (prefers-color-scheme:dark){
code{background-color:#1f2d3d;color:#c9d1d9}
.harper-container{
background:#0d1117;
border-color:#30363d;
box-shadow:0 4px 12px rgba(1,4,9,0.85)
}
.harper-header{color:#e6edf3}
.harper-body{color:#8b949e}
.harper-btn{
background:#21262d;
color:#c9d1d9
}
.harper-btn:hover{filter:brightness(1.15)}
.harper-close-btn{color:#8b949e;}
.harper-close-btn:hover{color:#e6edf3;}
.harper-gear-btn{color:#8b949e;}
.harper-gear-btn:hover{color:#e6edf3;}
.harper-btn[style*="background: #2DA44E"]{background:#238636}
.harper-btn[style*="background: #e5e5e5"]{
background:#4b4b4b;
color:#ffffff
}
}`,
]);
}
function ignoreLint(onIgnore: () => void | Promise<void>): any {
return button(
'Ignore',
{ background: '#e5e5e5', color: '#000000', fontWeight: 'lighter' },
onIgnore,
'Ignore this lint',
);
}
export default function SuggestionBox(
box: IgnorableLintBox,
actions: {
openOptions?: () => Promise<void>;
addToUserDictionary?: (words: string[]) => Promise<void>;
},
close: () => void,
) {
const top = box.y + box.height + 3;
let bottom: number | undefined;
const left = box.x;
if (top + 400 > window.innerHeight) {
bottom = window.innerHeight - box.y - 3;
}
const positionStyle: { [key: string]: string } = {
position: 'fixed',
top: bottom ? '' : `${top}px`,
bottom: bottom ? `${bottom}px` : '',
left: `${left}px`,
};
return h(
'div',
{
className: 'harper-container fade-in',
style: positionStyle,
'harper-close-on-escape': new CloseOnEscapeHook(close),
},
[
styleTag(),
header(
box.lint.lint_kind_pretty,
lintKindColor(box.lint.lint_kind),
close,
actions.openOptions,
),
body(box.lint.message_html),
footer(
suggestions(box.lint.suggestions, (v) => {
box.applySuggestion(v);
close();
}),
[
box.lint.lint_kind === 'Spelling' && actions.addToUserDictionary
? addToDictionary(box, actions.addToUserDictionary)
: undefined,
box.ignoreLint ? ignoreLint(box.ignoreLint) : undefined,
],
),
],
);
}

View file

@ -1,13 +1,24 @@
import type { ConditionalKeys, WritableKeysOf } from 'type-fest';
import { boxesOverlap, domRectToBox } from './Box';
/** A version of the `Range` object that works for `<textarea />` and `<input />` elements. */
export default class TextFieldRange {
field: HTMLTextAreaElement | HTMLInputElement;
mirror: HTMLElement | null;
mirrorTextNode: Text;
startOffset: number;
endOffset: number;
// Shared arena per field to avoid repeated layout work
private static arenas: WeakMap<
HTMLTextAreaElement | HTMLInputElement,
{
mirror: HTMLDivElement;
text: Text;
refs: number;
}
> = new WeakMap();
private arena: { mirror: HTMLDivElement; text: Text; refs: number };
/**
* Create a range-like object for a given text input field.
* @param field - A HTMLTextAreaElement or a HTMLInputElement (of type "text").
@ -26,21 +37,30 @@ export default class TextFieldRange {
this.field = field;
this.startOffset = startOffset;
this.endOffset = endOffset;
this.mirror = null;
this._createMirror();
this.arena = TextFieldRange.ensureArena(this.field);
this.arena.refs++;
}
/**
* Creates an off-screen mirror element that mimics the field's styles and positions it exactly over the field.
* Creates (or reuses) an off-screen mirror element that mimics the field's styles
* and positions it exactly over the field.
*/
private _createMirror(): void {
this.mirror = document.createElement('div');
this.mirror.id = 'textfield-mirror';
private static ensureArena(field: HTMLTextAreaElement | HTMLInputElement): {
mirror: HTMLDivElement;
text: Text;
refs: number;
} {
const existing = TextFieldRange.arenas.get(field);
if (existing) return existing;
const mirror = document.createElement('div');
mirror.className = 'harper-textfield-mirror';
// Copy necessary computed styles from the field (affecting text layout)
const computed: CSSStyleDeclaration = window.getComputedStyle(this.field);
// The properties below help ensure the mirror text has the same layout as the actual text.
const propertiesToCopy: Array<keyof CSSStyleDeclaration> = [
const computed: CSSStyleDeclaration = window.getComputedStyle(field);
const propertiesToCopy: Array<
ConditionalKeys<Pick<CSSStyleDeclaration, WritableKeysOf<CSSStyleDeclaration>>, string>
> = [
'fontFamily',
'fontSize',
'fontWeight',
@ -60,57 +80,66 @@ export default class TextFieldRange {
'overflowX',
'overflowY',
];
propertiesToCopy.forEach((prop) => {
this.mirror!.style[prop] = computed[prop];
(mirror.style as any)[prop] = (computed as any)[prop];
});
if (this.field instanceof HTMLTextAreaElement) {
this.mirror.style.overflowX = 'auto';
this.mirror.style.overflowY = 'auto';
if (field instanceof HTMLTextAreaElement) {
mirror.style.overflowX = 'auto';
mirror.style.overflowY = 'auto';
}
// Compute the absolute position of the field.
const fieldRect = this.field.getBoundingClientRect();
const scrollTop = window.scrollY || document.documentElement.scrollTop;
const scrollLeft = window.scrollX || document.documentElement.scrollLeft;
// Position the mirror exactly over the field.
Object.assign(this.mirror.style, {
top: `${fieldRect.top + scrollTop}px`,
left: `${fieldRect.left + scrollLeft}px`,
width: `${fieldRect.width}px`,
height: `${fieldRect.height}px`,
TextFieldRange.positionMirror(mirror, field);
Object.assign(mirror.style, {
boxSizing: 'border-box',
// For a textarea, use "pre-wrap" (so line-breaks are preserved); for a singleline input, use "pre"
whiteSpace: this.field.tagName.toLowerCase() === 'textarea' ? 'pre-wrap' : 'pre',
whiteSpace: field.tagName.toLowerCase() === 'textarea' ? 'pre-wrap' : 'pre',
wordWrap: 'break-word',
visibility: 'hidden',
position: 'absolute',
pointerEvents: 'none',
});
// Create the text node that will mirror the field's text.
this.mirrorTextNode = document.createTextNode('');
this.mirror.appendChild(this.mirrorTextNode);
const text = document.createTextNode('');
mirror.appendChild(text);
// Needed for the scroll to work.
this._updateMirrorText();
// Initialize text + scroll
text.nodeValue = field.value;
document.body.appendChild(mirror);
mirror.scrollTop = field.scrollTop;
mirror.scrollLeft = field.scrollLeft;
// Append the mirror element to the document body.
document.body.appendChild(this.mirror);
const arena = { mirror, text, refs: 0 } as const;
TextFieldRange.arenas.set(field, arena);
return arena;
}
this.mirror.scrollTo({
top: this.field.scrollTop,
left: this.field.scrollLeft,
behavior: 'instant',
private static positionMirror(
mirror: HTMLDivElement,
field: HTMLTextAreaElement | HTMLInputElement,
) {
const fieldRect = field.getBoundingClientRect();
const scrollTop = window.scrollY || document.documentElement.scrollTop;
const scrollLeft = window.scrollX || document.documentElement.scrollLeft;
Object.assign(mirror.style, {
top: `${fieldRect.top + scrollTop}px`,
left: `${fieldRect.left + scrollLeft}px`,
width: `${fieldRect.width}px`,
height: `${fieldRect.height}px`,
});
}
/**
* Updates the mirror's text node with the current value of the field.
*/
private _updateMirrorText(): void {
this.mirrorTextNode.nodeValue = this.field.value;
private syncMirror(): void {
// Ensure text, scroll, and position reflect the current field
this.arena.text.nodeValue = this.field.value;
this.arena.mirror.scrollTop = this.field.scrollTop;
this.arena.mirror.scrollLeft = this.field.scrollLeft;
TextFieldRange.positionMirror(this.arena.mirror, this.field);
}
/**
@ -119,11 +148,11 @@ export default class TextFieldRange {
* @returns {DOMRect[]} An array of DOMRect objects.
*/
getClientRects(): DOMRect[] {
this._updateMirrorText();
this.syncMirror();
const range = document.createRange();
range.setStart(this.mirrorTextNode, this.startOffset);
range.setEnd(this.mirrorTextNode, this.endOffset);
range.setStart(this.arena.text, this.startOffset);
range.setEnd(this.arena.text, this.endOffset);
let arr = Array.from(range.getClientRects());
@ -139,21 +168,22 @@ export default class TextFieldRange {
}
getBoundingClientRect(): DOMRect | null {
this._updateMirrorText();
if (this.mirror == null) {
return null;
}
return this.mirror.getBoundingClientRect();
this.syncMirror();
return this.arena.mirror.getBoundingClientRect();
}
/**
* Detaches (removes) the mirror element from the document.
*/
detach(): void {
if (this.mirror?.parentNode) {
this.mirror.parentNode.removeChild(this.mirror);
this.mirror = null;
// Release this handle; keep the shared mirror for reuse unless the field is gone.
this.arena.refs = Math.max(0, this.arena.refs - 1);
// If the field is no longer in the document, clean up the arena.
if (!document.contains(this.field)) {
try {
this.arena.mirror.parentNode?.removeChild(this.arena.mirror);
} catch {}
TextFieldRange.arenas.delete(this.field);
}
}
}

View file

@ -2,7 +2,6 @@ import type { Span } from 'harper.js';
import { domRectToBox, type IgnorableLintBox, isBottomEdgeInBox, shrinkBoxToFit } from './Box';
import { getRangeForTextSpan } from './domUtils';
import { getLexicalEditable, getSlateRoot } from './editorUtils';
import ProtocolClient from './ProtocolClient';
import TextFieldRange from './TextFieldRange';
import { applySuggestion, type UnpackedLint, type UnpackedSuggestion } from './unpackLint';
@ -16,21 +15,29 @@ function isFormEl(el: HTMLElement): el is HTMLTextAreaElement | HTMLInputElement
}
}
export default function computeLintBoxes(el: HTMLElement, lint: UnpackedLint): IgnorableLintBox[] {
export default function computeLintBoxes(
el: HTMLElement,
lint: UnpackedLint,
opts: { ignoreLint?: (hash: string) => Promise<void> },
): IgnorableLintBox[] {
try {
let range: Range | TextFieldRange | null = null;
let text: string | null = null;
if (isFormEl(el)) {
range = new TextFieldRange(el, lint.span.start, lint.span.end);
text = el.value;
} else {
range = getRangeForTextSpan(el, lint.span as Span);
}
const targetRects = range.getClientRects();
const elBox = domRectToBox(range.getBoundingClientRect());
range.detach();
if (!range) {
return [];
}
const targetRects = Array.from(
(range as Range).getClientRects ? (range as Range).getClientRects() : [],
);
const elBox = domRectToBox((range as Range).getBoundingClientRect());
(range as any).detach?.();
const boxes: IgnorableLintBox[] = [];
@ -46,7 +53,7 @@ export default function computeLintBoxes(el: HTMLElement, lint: UnpackedLint): I
return [];
}
for (const targetRect of targetRects) {
for (const targetRect of targetRects as DOMRect[]) {
if (!isBottomEdgeInBox(targetRect, elBox)) {
continue;
}
@ -61,9 +68,12 @@ export default function computeLintBoxes(el: HTMLElement, lint: UnpackedLint): I
lint,
source,
applySuggestion: (sug: UnpackedSuggestion) => {
replaceValue(el, applySuggestion(el.value ?? el.textContent, lint.span, sug));
const current = isFormEl(el)
? (el as HTMLInputElement | HTMLTextAreaElement).value
: (el.textContent ?? '');
replaceValue(el, applySuggestion(current, lint.span, sug));
},
ignoreLint: () => ProtocolClient.ignoreHash(lint.context_hash),
ignoreLint: opts.ignoreLint ? () => opts.ignoreLint!(lint.context_hash) : undefined,
});
}
return boxes;
@ -79,12 +89,12 @@ function replaceValue(el: HTMLElement, value: string) {
if (isFormEl(el)) {
el.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, data: value }));
el.value = value;
(el as any).value = value;
el.dispatchEvent(new InputEvent('input', { bubbles: true }));
} else if (slateRoot != null || lexicalRoot != null) {
replaceValueSpecial(el, value);
} else {
el.textContent = value;
(el as any).textContent = value;
el.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, data: value }));
el.dispatchEvent(new InputEvent('input', { bubbles: true }));

View file

@ -7,11 +7,10 @@ import { isBoxInScreen } from './Box';
*/
export function extractFromHTMLCollection(collection: HTMLCollection): Element[] {
const elements: Element[] = [];
for (const el of collection) {
elements.push(el);
for (let i = 0; i < collection.length; i++) {
const el = collection.item(i);
if (el) elements.push(el);
}
return elements;
}
@ -88,26 +87,33 @@ export function getRangeForTextSpan(target: Element, span: Span): Range | null {
return null;
}
const sharedRange: Range | null = typeof document !== 'undefined' ? document.createRange() : null;
/** Check if an element is visible to the user.
*
* It is coarse and meant for performance improvements, not precision.*/
export function isVisible(node: Node): boolean {
try {
if (!node || !(node as any).ownerDocument) return false;
if (node instanceof Element) {
return node.checkVisibility();
if (!node.isConnected) return false;
const rect = node.getBoundingClientRect();
if (!isBoxInScreen(rect)) return false;
const cv = (node as any).checkVisibility;
if (typeof cv === 'function') return cv.call(node);
const cs = getComputedStyle(node);
if (cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0') return false;
return true;
}
const range = document.createRange();
range.selectNode(node);
const rects = range.getClientRects();
for (const rect of rects) {
if (isBoxInScreen(rect)) {
return true;
}
}
} catch (e) {
if (!sharedRange) return false;
const parent = (node as any).parentElement as Element | null;
if (parent && !parent.isConnected) return false;
sharedRange.selectNode(node);
const rect = sharedRange.getBoundingClientRect();
return isBoxInScreen(rect);
} catch {
return false;
}
return false;
}

View file

@ -0,0 +1,152 @@
import { type Box, domRectToBox } from './Box';
import type SourceElement from './SourceElement';
import TextFieldRange from './TextFieldRange';
export function findAncestor(
el: SourceElement,
predicate: (el: SourceElement) => boolean,
): SourceElement | null {
let current: SourceElement | null = el;
while (current != null) {
if (predicate(current)) return current;
current = current.parentElement;
}
return null;
}
export function getGhostRoot(el: SourceElement): SourceElement | null {
return findAncestor(
el,
(node: SourceElement) => !isTextNode(node) && node.closest('article, main, section') != null,
);
}
export function getDraftRoot(el: SourceElement): SourceElement | null {
return findAncestor(
el,
(node: SourceElement) =>
!isTextNode(node) && node.classList.contains('public-DraftEditor-content'),
);
}
export function getPMRoot(el: SourceElement): SourceElement | null {
return findAncestor(
el,
(node: SourceElement) => !isTextNode(node) && node.classList.contains('ProseMirror'),
);
}
export function getCMRoot(el: SourceElement): SourceElement | null {
return findAncestor(
el,
(node: SourceElement) => !isTextNode(node) && node.classList.contains('cm-editor'),
);
}
export function getNotionRoot(): SourceElement | null {
return document.getElementById('notion-app');
}
export function getSlateRoot(el: SourceElement): SourceElement | null {
return findAncestor(
el,
(node: SourceElement) => !isTextNode(node) && node.getAttribute('data-slate-editor') === 'true',
);
}
export function getLexicalRoot(el: SourceElement): SourceElement | null {
return findAncestor(
el,
(node: SourceElement) =>
!isTextNode(node) && node.getAttribute('data-lexical-editor') === 'true',
);
}
export function getLexicalEditable(el: SourceElement): SourceElement | null {
return findAncestor(
el,
(node: SourceElement) => !isTextNode(node) && node.getAttribute('contenteditable') === 'true',
);
}
export function getMediumRoot(el: SourceElement): SourceElement | null {
return findAncestor(
el,
(node: SourceElement) => node.nodeName == 'MAIN' && location.hostname == 'medium.com',
);
}
export function getShredditComposerRoot(el: SourceElement): SourceElement | null {
return findAncestor(
el,
(node: SourceElement) => !isTextNode(node) && node.nodeName == 'SHREDDIT-COMPOSER',
);
}
export function getQuillJsRoot(el: SourceElement): SourceElement | null {
return findAncestor(
el,
(node: SourceElement) => !isTextNode(node) && node.classList.contains('ql-container'),
);
}
export function getP2Root(el: SourceElement): SourceElement | null {
return findAncestor(
el,
(node: SourceElement) =>
!isTextNode(node) && (node.id === 'p2' || node.classList.contains('p2')),
);
}
export function getGutenbergRoot(el: SourceElement): SourceElement | null {
return findAncestor(
el,
(node: SourceElement) =>
!isTextNode(node) &&
(node.id === 'editor' || node.classList.contains('editor-styles-wrapper')),
);
}
export function getTrixRoot(el: SourceElement): SourceElement | null {
return findAncestor(el, (node: SourceElement) => node.nodeName == 'TRIX-EDITOR');
}
export function getCaretPosition(): Box | null {
const active = document.activeElement;
if (
active instanceof HTMLTextAreaElement ||
(active instanceof HTMLInputElement && active.type === 'text')
) {
if (
active.selectionStart == null ||
active.selectionEnd == null ||
active.selectionStart !== active.selectionEnd
) {
return null;
}
const offset = active.selectionStart;
const tfRange = new TextFieldRange(active, offset, offset);
const rects = tfRange.getClientRects();
tfRange.detach();
return rects.length ? domRectToBox(rects[0]) : null;
}
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return null;
const range = selection.getRangeAt(0);
if (!range.collapsed) return null;
return domRectToBox(range.getBoundingClientRect());
}
export function isFormEl(el: any): el is HTMLInputElement | HTMLTextAreaElement {
return el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement;
}
export function isTextNode(el: SourceElement): el is Text {
return el.nodeType === Node.TEXT_NODE;
}

View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

View file

@ -0,0 +1,32 @@
import { resolve } from 'path';
import { defineConfig } from 'vite';
import dts from 'vite-plugin-dts';
export default defineConfig({
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
name: 'lintFramework',
fileName: 'index',
formats: ['es'],
},
minify: true,
rollupOptions: {
external: ['harper.js'],
output: {
inlineDynamicImports: true,
minifyInternalExports: true,
},
treeshake: {
moduleSideEffects: false,
propertyReadSideEffects: false,
},
},
},
plugins: [
dts({
rollupTypes: true,
tsconfigPath: './tsconfig.json',
}),
],
});

View file

@ -2,7 +2,6 @@ import { Dialect } from 'harper.js';
import { type App, editorViewField, Menu, Notice, Plugin, type PluginManifest } from 'obsidian';
import logoSvg from '../logo.svg?raw';
import logoSvgDisabled from '../logo-disabled.svg?raw';
import packageJson from '../package.json';
import { HarperSettingTab } from './HarperSettingTab';
import State from './State';

View file

@ -35,6 +35,7 @@
"@sveltepress/vite": "^1.1.5",
"chart.js": "^4.4.8",
"harper.js": "workspace:*",
"lint-framework": "workspace:*",
"lodash-es": "^4.17.21",
"posthog-js": "^1.245.1",
"reveal.js": "^5.1.0",

View file

@ -1,19 +0,0 @@
<script lang="ts">
import { draw } from 'svelte/transition';
export let width = '100%';
export let height = '100%';
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="3"
stroke="currentColor"
class="size-6"
{width}
{height}
>
<path in:draw stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>

View file

@ -1,20 +1,40 @@
<script lang="ts">
import CheckMark from '$lib/CheckMark.svelte';
import Underlines from '$lib/Underlines.svelte';
import { Button, Card } from 'flowbite-svelte';
import { type Lint, SuggestionKind, type WorkerLinter } from 'harper.js';
import { fly } from 'svelte/transition';
import LintCard from '$lib/LintCard.svelte';
import { Card } from 'flowbite-svelte';
import { type WorkerLinter } from 'harper.js';
import {
applySuggestion,
LintFramework,
type UnpackedLint,
type UnpackedSuggestion,
unpackLint,
} from 'lint-framework';
import demo from '../../../../demo.md?raw';
import lintKindColor from './lintKindColor';
export let content = demo;
export let content = demo.trim();
let lints: Lint[] = [];
let lintCards: HTMLButtonElement[] = [];
let focused: number | undefined;
let editor: HTMLTextAreaElement | null;
let linter: WorkerLinter;
// Live list of lints from the framework's lint callback
let lints: UnpackedLint[] = [];
let openIndex: number | null = null;
let lfw = new LintFramework(async (text) => {
// Guard until the linter is ready
if (!linter) return [];
const raw = await linter.lint(text);
// The framework expects "unpacked" lints with plain fields
const unpacked = await Promise.all(
raw.map((lint) => unpackLint(window.location.hostname, lint, linter)),
);
lints = unpacked;
return unpacked;
}, {});
(async () => {
let { WorkerLinter, binary } = await import('harper.js');
linter = new WorkerLinter({ binary });
@ -22,138 +42,67 @@ let linter: WorkerLinter;
await linter.setup();
})();
let w: number | undefined;
$: linter?.lint(content).then((newLints) => {
lints = newLints;
});
$: boxHeight = calcHeight(content);
$: if (focused != null && lintCards[focused])
lintCards[focused].scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
$: if (focused != null && focused >= lints.length) focused = undefined;
$: if (editor != null && focused != null) {
let lint = lints[focused % lints.length];
if (lint != null) {
let p = lint.span().end;
editor.selectionStart = p;
editor.selectionEnd = p;
}
$: if (editor != null) {
lfw.addTarget(editor);
}
function calcHeight(boxContent: string): number {
let numberOfLineBreaks = (boxContent.match(/\n/g) || []).length;
let newHeight = 20 + numberOfLineBreaks * 30 + 12 + 2;
return newHeight;
function applySug(lint: UnpackedLint, s: UnpackedSuggestion) {
content = applySuggestion(content, lint.span, s);
// Trigger re-lint and rerender after programmatic change
lfw.update();
}
// Whether to display a smallar variant of the editor
$: small = (w ?? 1024) < 1024;
$: superSmall = (w ?? 1024) < 550;
function createSnippetFor(lint: UnpackedLint) {
const CONTEXT = 60;
const start = Math.max(0, lint.span.start - CONTEXT);
const end = Math.min(content.length, lint.span.end + CONTEXT);
let prefix = content.slice(start, lint.span.start);
let suffix = content.slice(lint.span.end, end);
// Collapse whitespace/newlines for a compact blurb
const collapse = (s: string) => s.replace(/\s+/g, ' ').trim();
prefix = collapse(prefix);
const problem = collapse(lint.problem_text);
suffix = collapse(suffix);
return {
prefix,
problem,
suffix,
prefixEllipsis: start > 0,
suffixEllipsis: end < content.length,
};
}
</script>
<div class={`flex w-full h-full p-5 ${small ? 'flex-col' : 'flex-row'}`} bind:clientWidth={w}>
<Card
class="flex-grow h-full p-5 grid z-10 max-w-full text-lg overflow-auto mr-5"
on:click={() => editor && editor.focus()}
>
<div class="flex flex-row h-full max-w-full">
<Card class="flex-1 h-full p-5 z-10 max-w-full text-lg mr-5">
<textarea
bind:this={editor}
class="w-full text-nowrap m-0 rounded-none p-0 z-0 bg-transparent overflow-hidden border-none text-lg resize-none focus:border-0"
spellcheck="false"
data-enable-grammarly="false"
style={`grid-row: 1; grid-column: 1; height: ${boxHeight}px`}
on:keydown={() => (focused = undefined)}
class="w-full m-0 rounded-none p-0 z-0 bg-transparent h-full border-none text-lg resize-none focus:border-0"
bind:value={content}
></textarea>
<div class="m-0 p-0 z-10 pointer-events-none" style="grid-row: 1; grid-column: 1">
<Underlines {content} bind:focusLintIndex={focused} />
</div>
</Card>
<Card class={`flex-none basis-[400px] max-h-full p-1 ${small ? 'hidden' : 'flex'}`}>
<h2 class="text-2xl font-bold m-2">Suggestions</h2>
<div class="flex flex-col overflow-y-auto overflow-x-hidden m-0 p-0 h-full">
{#if lints.length == 0}
<div class="w-full h-full flex flex-row text-center justify-center items-center" in:fly>
<p class="dark:white font-bold text-lg">Looks good to me</p>
<CheckMark />
</div>
{/if}
{#each lints as lint, i}
<button
class="block max-w-sm p-3 bg-white dark:bg-gray-800 border border-gray-200 rounded-lg shadow m-1 hover:translate-x-1 transition-all"
on:click={() => (focused = i)}
bind:this={lintCards[i]}
>
<div class={`pl-2`} style={`border-left: 4px solid ${lintKindColor(lint.lint_kind())}`}>
<div class="flex flex-row">
<h3 class="font-bold text-base p-0">
{lint.lint_kind_pretty()} - “<span class="italic">
{lint.get_problem_text()}
</span>
</h3>
</div>
<div
class="transition-all overflow-hidden flex flex-col justify-evenly"
style={`height: ${focused === i ? `calc(55px * ${lint.suggestion_count() + 1})` : '0px'}`}
>
<p style="height: 50px" class="text-left text-sm p-0">{@html lint.message_html().replaceAll('<p>', "").replaceAll('<p />', "")}</p>
{#each lint.suggestions() as suggestion}
<div class="w-full p-[4px]">
<Button
class="w-full"
style="height: 40px; margin: 5px 0px;"
on:click={() =>
linter
.applySuggestion(content, lint, suggestion)
.then((edited) => (content = edited))}
>
{#if suggestion.kind() == SuggestionKind.Remove}
Remove "{lint.get_problem_text()}"
{:else if suggestion.kind() == SuggestionKind.Replace}
Replace "{lint.get_problem_text()}" with "{suggestion.get_replacement_text()}"
{:else}
Insert "{suggestion.get_replacement_text()}" after "{lint.get_problem_text()}"
{/if}
</Button>
</div>
{/each}
</div>
</div>
</button>
{/each}
</div>
</Card>
{#if focused != null}
<Card
class={`max-w-full w-full ${superSmall ? 'justify-center' : 'justify-between'} flex-row ${small ? '' : 'hidden'}`}
>
<div class={superSmall ? 'hidden' : ''}>
<h1 class={`font-bold p-0 text-base`}>{lints[focused].lint_kind_pretty()}</h1>
<p class={`p-0 text-sm`}>{@html lints[focused].message_html().replaceAll('<p>', "").replaceAll('<p />', "")}</p>
</div>
<div class="flex flex-row">
{#each lints[focused].suggestions() as suggestion}
<div class="p-[4px]">
<Button
class="w-full"
style="height: 40px; margin: 5px 0px;"
on:click={() =>
focused != null &&
linter
.applySuggestion(content, lints[focused], suggestion)
.then((edited) => (content = edited))}
>
{#if suggestion.kind() == SuggestionKind.Remove}
Remove
{:else}
"{suggestion.get_replacement_text()}"
{/if}
</Button>
</div>
{/each}
</div>
</Card>
{/if}
<Card class="hidden md:flex md:flex-col md:w-1/3 h-full p-5 z-10">
<div class="text-base font-semibold mb-3">Problems</div>
<div class="flex-1 overflow-y-auto pr-1">
{#if lints.length === 0}
<p class="text-sm text-gray-500">No lints yet.</p>
{:else}
<div class="space-y-3">
{#each lints as lint, i}
<LintCard
{lint}
snippet={createSnippetFor(lint)}
open={openIndex === i}
onToggle={() => (openIndex = openIndex === i ? null : i)}
onApply={(s) => applySug(lint, s)}
/>
{/each}
</div>
{/if}
</div>
</Card>
</div>

View file

@ -0,0 +1,74 @@
<script lang="ts">
import { lintKindColor, type UnpackedLint, type UnpackedSuggestion } from 'lint-framework';
import { slide } from 'svelte/transition';
export let lint: UnpackedLint;
export let open = false;
export let onToggle: () => void;
export let onApply: (s: UnpackedSuggestion) => void;
export let snippet: {
prefix: string;
problem: string;
suffix: string;
prefixEllipsis: boolean;
suffixEllipsis: boolean;
};
function suggestionText(s: UnpackedSuggestion): string {
return s.replacement_text !== '' ? s.replacement_text : String(s.kind);
}
</script>
<div class="rounded-lg border border-gray-300 dark:border-gray-700 shadow-sm bg-white dark:bg-[#0d1117]">
<div
role="button"
tabindex="0"
class="flex items-center justify-between p-3 cursor-pointer select-none"
aria-expanded={open}
on:click={() => onToggle?.()}
on:keydown={(e) => (e.key === 'Enter' || e.key === ' ') && (e.preventDefault(), onToggle?.())}
>
<div
class="text-sm font-semibold pb-1"
style={`border-bottom: 2px solid ${lintKindColor(lint.lint_kind)}`}
>
{lint.lint_kind_pretty}
</div>
<svg
class={`ml-3 h-4 w-4 transform transition-transform duration-200 ${open ? 'rotate-180' : ''}`}
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.127l3.71-3.896a.75.75 0 111.08 1.04l-4.243 4.46a.75.75 0 01-1.08 0L5.25 8.27a.75.75 0 01-.02-1.06z" clip-rule="evenodd" />
</svg>
</div>
{#if open}
<div class="px-3 pb-3" in:slide={{ duration: 150 }} out:slide={{ duration: 150 }}>
<div class="text-sm text-gray-700 dark:text-gray-300 mb-2 break-words">
{@html lint.message_html}
</div>
<div class="text-xs font-mono mb-2 p-2 rounded border border-gray-200 dark:border-gray-800 bg-gray-50 dark:bg-[#0b0f14] text-gray-800 dark:text-gray-200 leading-snug">
<span class="text-gray-500">{snippet.prefixEllipsis ? '…' : ''}{snippet.prefix}</span>
<span class="px-0.5 rounded bg-yellow-200 text-black dark:bg-yellow-800 dark:text-yellow-100">{snippet.problem}</span>
<span class="text-gray-500">{snippet.suffix}{snippet.suffixEllipsis ? '…' : ''}</span>
</div>
{#if lint.suggestions && lint.suggestions.length > 0}
<div class="flex flex-wrap gap-2 justify-end">
{#each lint.suggestions as s}
<button
class="inline-flex items-center justify-center rounded-md px-2 py-1 text-xs font-semibold"
style="background:#2DA44E;color:#FFFFFF"
title={`Replace with \"${suggestionText(s)}\"`}
on:click={() => onApply?.(s)}
>
{suggestionText(s)}
</button>
{/each}
</div>
{:else}
<div class="text-xs text-gray-400">No suggestions available.</div>
{/if}
</div>
{/if}
</div>

View file

@ -1,151 +0,0 @@
<script lang="ts">
// This is some of the shittiest code I've ever written.
// It is quite hard to look at.
// Someday, I'll return to it and spruce it up.
// For now, it works.
import lintKindColor from '$lib/lintKindColor';
import type { Lint, WorkerLinter } from 'harper.js';
export let content: string;
export let focusLintIndex: number | undefined;
import { quintOut } from 'svelte/easing';
let loadTime = Date.now();
function slideUnderline(_node: HTMLElement) {
return {
duration: 300,
css: (t: number) => {
if (Date.now() - loadTime > 2000) {
t = 1;
}
return `
transform: scaleX(${t});
transform-origin: left;
`;
},
easing: quintOut,
};
}
let lints: [Lint, number][] = [];
let lintHighlights: HTMLSpanElement[] = [];
let linter: WorkerLinter;
(async () => {
let { WorkerLinter, binary } = await import('harper.js');
linter = new WorkerLinter({ binary });
await linter.setup();
})();
$: linter?.lint(content).then((newLints) => {
lints = newLints
.map<[Lint, number]>((lint, index) => [lint, index])
.toSorted(([a], [b]) => a.span().start - b.span().end);
});
$: if (focusLintIndex != null && lintHighlights[focusLintIndex] != null)
lintHighlights[focusLintIndex].scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'nearest',
});
function reOrgString(text: string): (string | undefined)[] {
let output: (string | undefined)[] = [];
for (let chunk of text.replaceAll(' ', '\u00A0').split('\n')) {
if (output.length > 0) {
output.push(undefined);
}
output.push(chunk);
}
return output;
}
type UnderlineDetails = {
focused: boolean;
content: string;
index: number;
color: string;
context: string;
};
type UnderlineToken = string | null | undefined | UnderlineDetails;
function processString(lintMap: [Lint, number][], focusLintIndex?: number) {
let results: UnderlineToken[] = lintMap.flatMap(([lint, lintIndex], index, arr) => {
let prevStart = 0;
let prev = arr[index - 1];
if (prev != null) {
prevStart = prev[0].span().end;
}
let prevEnd = lint.span().start;
let prevContent = [];
if (prevStart !== prevEnd) {
prevContent.push(...reOrgString(content.substring(prevStart, prevEnd)));
}
let lintContent: UnderlineDetails = {
focused: lintIndex === focusLintIndex,
index: lintIndex,
content: lint.get_problem_text().replaceAll(' ', '\u00A0'),
color: lintKindColor(lint.lint_kind()),
context: prevContent[prevContent.length - 1] ?? '',
};
return [...prevContent, lintContent];
});
let lastLint = lints.at(-1);
let finalChunk: string;
if (lastLint != null) {
finalChunk = content.substring(lastLint[0].span().end);
} else {
finalChunk = content;
}
results.push(...reOrgString(finalChunk));
return results;
}
// string | [string, string, string, index] | null
$: modified = processString(lints, focusLintIndex);
</script>
<div class="grid">
<div class="p-0 m-0 text-nowrap indent-0" style="grid-row: 1; grid-column: 1">
{#each modified as chunk}
{#if chunk == null}
<br />
{:else if typeof chunk == 'string'}
<span class="whitespace-pre !text-transparent">{chunk}</span>
{:else}
<span class="pointer-events-auto">
<button
class={`underlinespecial transition-all rounded-sm ${chunk.focused ? 'animate-after-bigbounce text-white' : 'text-transparent'}`}
bind:this={lintHighlights[chunk.index]}
in:slideUnderline
on:click={() =>
chunk != null && typeof chunk == 'object' && (focusLintIndex = chunk.index)}
style={`--line-color: ${chunk.color}; --line-width: ${chunk.focused ? '4px' : '2px'}; --bg-color: ${chunk.focused ? '#dbafb3' : 'transparent'};`}
>
{chunk.content}
</button>
</span>
{/if}
{/each}
</div>
</div>

View file

@ -18,6 +18,8 @@ The content script has three responsibilities:
- Writing text back to the user's web page (after applying a suggestion to it).
- Rendering underlines over their text (this is the hard part).
All three of these responsibilities are handled by the `lint-framework` package.
Notably, it does not do any linting itself.
Instead, it submits requests to the background worker to do so, since instantiating a WebAssembly module on every page load is expensive.

46
pnpm-lock.yaml generated
View file

@ -52,6 +52,9 @@ importers:
jquery:
specifier: ^3.7.1
version: 3.7.1
lint-framework:
specifier: workspace:*
version: link:../lint-framework
lodash-es:
specifier: ^4.17.21
version: 4.17.21
@ -61,9 +64,6 @@ importers:
tailwindcss:
specifier: ^4.1.4
version: 4.1.4
virtual-dom:
specifier: ^2.1.1
version: 2.1.1
devDependencies:
'@crxjs/vite-plugin':
specifier: ^2.0.0-beta.26
@ -86,9 +86,6 @@ importers:
'@types/node':
specifier: 'catalog:'
version: 22.13.10
'@types/virtual-dom':
specifier: ^2.1.4
version: 2.1.4
flowbite:
specifier: ^3.1.2
version: 3.1.2(rollup@4.35.0)
@ -183,6 +180,31 @@ importers:
specifier: workspace:*
version: link:../..
packages/lint-framework:
dependencies:
harper.js:
specifier: workspace:*
version: link:../harper.js
virtual-dom:
specifier: ^2.1.1
version: 2.1.1
devDependencies:
'@types/virtual-dom':
specifier: ^2.1.4
version: 2.1.4
type-fest:
specifier: ^4.37.0
version: 4.37.0
typescript:
specifier: 'catalog:'
version: 5.8.2
vite:
specifier: ^6.1.0
version: 6.3.5(@types/node@22.13.10)(jiti@2.4.2)(lightningcss@1.29.2)(sass@1.85.1)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.0)
vite-plugin-dts:
specifier: ^4.5.0
version: 4.5.3(@types/node@22.13.10)(rollup@4.35.0)(typescript@5.8.2)(vite@6.3.5(@types/node@22.13.10)(jiti@2.4.2)(lightningcss@1.29.2)(sass@1.85.1)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.0))
packages/obsidian-plugin:
dependencies:
'@codemirror/autocomplete':
@ -304,6 +326,9 @@ importers:
harper.js:
specifier: workspace:*
version: link:../harper.js
lint-framework:
specifier: workspace:*
version: link:../lint-framework
lodash-es:
specifier: ^4.17.21
version: 4.17.21
@ -7784,10 +7809,6 @@ packages:
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
lru-cache@11.0.2:
resolution: {integrity: sha512-123qHRfJBmo2jXDbo/a5YOQrJoHF/GNQTLzQ5+IdK5pWpceK17yRc6ozlWd25FxvGKQbIUs91fDFkXmDHTKcyA==}
engines: {node: 20 || >=22}
lru-cache@11.1.0:
resolution: {integrity: sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==}
engines: {node: 20 || >=22}
@ -9813,6 +9834,7 @@ packages:
source-map@0.8.0-beta.0:
resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==}
engines: {node: '>= 8'}
deprecated: The work that was done in this beta branch won't be included in future versions
sourcemap-codec@1.4.8:
resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==}
@ -21266,8 +21288,6 @@ snapshots:
lru-cache@10.4.3: {}
lru-cache@11.0.2: {}
lru-cache@11.1.0: {}
lru-cache@5.1.1:
@ -22345,7 +22365,7 @@ snapshots:
path-scurry@2.0.0:
dependencies:
lru-cache: 11.0.2
lru-cache: 11.1.0
minipass: 7.1.2
path-to-regexp@0.1.12: {}