mirror of
https://github.com/Automattic/harper.git
synced 2025-12-23 08:48:15 +00:00
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
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:
parent
191f27f727
commit
353b8cd5bb
40 changed files with 1042 additions and 952 deletions
|
|
@ -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}
|
||||
|
|
|
|||
19
justfile
19
justfile
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>>({
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
1
packages/lint-framework/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
dist
|
||||
5
packages/lint-framework/README.md
Normal file
5
packages/lint-framework/README.md
Normal 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).
|
||||
36
packages/lint-framework/package.json
Normal file
36
packages/lint-framework/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1
packages/lint-framework/src/assets/bookDownSvg.ts
Normal file
1
packages/lint-framework/src/assets/bookDownSvg.ts
Normal 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>`;
|
||||
12
packages/lint-framework/src/index.ts
Normal file
12
packages/lint-framework/src/index.ts
Normal 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';
|
||||
|
|
@ -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. */
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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. */
|
||||
4
packages/lint-framework/src/lint/SourceElement.ts
Normal file
4
packages/lint-framework/src/lint/SourceElement.ts
Normal 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;
|
||||
330
packages/lint-framework/src/lint/SuggestionBox.ts
Normal file
330
packages/lint-framework/src/lint/SuggestionBox.ts
Normal 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,
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
@ -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 single‑line 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }));
|
||||
|
|
@ -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;
|
||||
}
|
||||
152
packages/lint-framework/src/lint/editorUtils.ts
Normal file
152
packages/lint-framework/src/lint/editorUtils.ts
Normal 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;
|
||||
}
|
||||
21
packages/lint-framework/tsconfig.json
Normal file
21
packages/lint-framework/tsconfig.json
Normal 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"]
|
||||
}
|
||||
32
packages/lint-framework/vite.config.ts
Normal file
32
packages/lint-framework/vite.config.ts
Normal 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',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
74
packages/web/src/lib/LintCard.svelte
Normal file
74
packages/web/src/lib/LintCard.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
46
pnpm-lock.yaml
generated
|
|
@ -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: {}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue