Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ npm run build -w @techword/browser
`chrome://extensions` → デベロッパーモード ON → 「パッケージ化されていない拡張機能を読み込む」
で `packages/browser/dist` を選択。任意のページで用語にホバーします。

ホバー内の `☆` を押すと用語をお気に入りに追加でき、もう一度押すと解除できます。
Chrome のツールバーにある TechTerm のアイコンを押すとサイドパネルが開き、
お気に入りの確認、個別削除、全削除ができます。お気に入りは Chrome の同期ストレージに保存されます。

## 辞書を増やす

`packages/core/src/data/terms.json` に追記します。
Expand Down
38 changes: 38 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions packages/browser/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,18 @@
"manifest_version": 3,
"name": "TechTerm",
"version": "0.1.1",
"minimum_chrome_version": "114",
"description": "プログラミング用語にホバーすると、意味を簡潔に表示します。",
"permissions": ["storage", "sidePanel"],
"action": {
"default_title": "TechTermのお気に入りを開く"
},
"background": {
"service_worker": "background.js"
},
"side_panel": {
"default_path": "sidepanel.html"
},
"content_scripts": [
{
"matches": [
Expand Down
7 changes: 5 additions & 2 deletions packages/browser/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@
"private": true,
"typescript": "^6.0.3",
"scripts": {
"build": "esbuild src/content.ts --bundle --outfile=dist/content.js --format=iife --target=chrome110 && cp manifest.json dist/manifest.json",
"watch": "esbuild src/content.ts --bundle --outfile=dist/content.js --format=iife --target=chrome110 --watch"
"build": "tsc -p tsconfig.json --noEmit && esbuild src/content.ts src/background.ts src/sidepanel.ts --bundle --outdir=dist --format=iife --target=chrome114 && cp manifest.json sidepanel.html sidepanel.css dist/",
"watch": "mkdir -p dist && cp manifest.json sidepanel.html sidepanel.css dist/ && esbuild src/content.ts src/background.ts src/sidepanel.ts --bundle --outdir=dist --format=iife --target=chrome114 --watch"
},
"dependencies": {
"@techword/core": "*"
},
"devDependencies": {
"@types/chrome": "^0.2.9"
}
}
134 changes: 134 additions & 0 deletions packages/browser/sidepanel.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
:root {
color-scheme: light dark;
font-family: -apple-system, BlinkMacSystemFont, "Hiragino Sans", "Noto Sans JP", sans-serif;
background: Canvas;
color: CanvasText;
}

* {
box-sizing: border-box;
}

body {
margin: 0;
}

main {
padding: 18px 16px 28px;
}

header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}

h1,
h2,
p {
margin: 0;
}

h1 {
font-size: 20px;
line-height: 1.3;
}

.count {
margin-top: 3px;
color: GrayText;
font-size: 12px;
}

button {
border: 1px solid color-mix(in srgb, CanvasText 24%, transparent);
border-radius: 7px;
background: ButtonFace;
color: ButtonText;
cursor: pointer;
font: inherit;
}

button:hover:not(:disabled) {
background: color-mix(in srgb, ButtonFace 80%, CanvasText 20%);
}

button:focus-visible,
a:focus-visible {
outline: 2px solid Highlight;
outline-offset: 2px;
}

button:disabled {
cursor: default;
opacity: 0.5;
}

.clear-favorites {
flex: none;
padding: 7px 10px;
font-size: 12px;
}

.favorites {
display: grid;
gap: 10px;
margin: 0;
padding: 0;
list-style: none;
}

.favorite-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px;
border: 1px solid color-mix(in srgb, CanvasText 18%, transparent);
border-radius: 9px;
}

.favorite-body {
flex: 1;
min-width: 0;
}

.favorite-body h2 {
font-size: 15px;
line-height: 1.4;
}

.favorite-body p {
margin-top: 4px;
color: color-mix(in srgb, CanvasText 76%, transparent);
font-size: 13px;
line-height: 1.6;
}

.favorite-body a {
display: inline-block;
margin-top: 6px;
color: LinkText;
font-size: 12px;
}

.remove-favorite {
flex: none;
padding: 6px 8px;
color: #c62828;
font-size: 12px;
}

.empty {
padding: 30px 8px;
color: GrayText;
text-align: center;
font-size: 13px;
}

.error {
margin-bottom: 12px;
color: #c62828;
font-size: 13px;
}
27 changes: 27 additions & 0 deletions packages/browser/sidepanel.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<!doctype html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>TechTerm お気に入り</title>
<link rel="stylesheet" href="sidepanel.css" />
</head>
<body>
<main>
<header>
<div>
<h1>お気に入り</h1>
<p id="count" class="count" aria-live="polite">0件</p>
</div>
<button id="clear-favorites" class="clear-favorites" type="button" disabled>
すべて削除
</button>
</header>

<p id="error" class="error" role="alert" hidden></p>
<p id="empty" class="empty">お気に入りはまだありません。</p>
<ul id="favorites" class="favorites" aria-label="お気に入りの用語" hidden></ul>
</main>
<script src="sidepanel.js"></script>
</body>
</html>
3 changes: 3 additions & 0 deletions packages/browser/src/background.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
chrome.sidePanel
.setPanelBehavior({ openPanelOnActionClick: true })
.catch((error: unknown) => console.error('TechTerm: サイドパネルを設定できませんでした。', error));
83 changes: 81 additions & 2 deletions packages/browser/src/content.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Dictionary, builtinTerms, type Match } from '@techword/core';
import { getFavoriteIds, toggleFavorite, watchFavoriteIds } from './favorites';

const dictionary = new Dictionary(builtinTerms);

Expand All @@ -8,14 +9,39 @@ const HOVER_DELAY_MS = 250;
const tooltip = createTooltip();
let hoverTimer: number | undefined;
let currentId: string | undefined;
let currentFavoriteButton: HTMLButtonElement | undefined;
let favoriteIds = new Set<string>();
let pointerInsideTooltip = false;

void getFavoriteIds()
.then((ids) => {
favoriteIds = new Set(ids);
refreshCurrentFavoriteButton();
})
.catch((error: unknown) => console.error('TechTerm: お気に入りを読み込めませんでした。', error));

watchFavoriteIds((ids) => {
favoriteIds = new Set(ids);
refreshCurrentFavoriteButton();
});

document.addEventListener('mousemove', (event) => {
window.clearTimeout(hoverTimer);
if (event.composedPath().includes(tooltip.host) || pointerInsideTooltip) return;
hoverTimer = window.setTimeout(() => handleHover(event), HOVER_DELAY_MS);
});
document.addEventListener('scroll', hide, { passive: true, capture: true });
window.addEventListener('blur', hide);

tooltip.host.addEventListener('pointerenter', () => {
pointerInsideTooltip = true;
window.clearTimeout(hoverTimer);
});
tooltip.host.addEventListener('pointerleave', () => {
pointerInsideTooltip = false;
hide();
});

function handleHover(event: MouseEvent): void {
const caret = caretFromPoint(event.clientX, event.clientY);
if (!caret || caret.node.nodeType !== Node.TEXT_NODE) return hide();
Expand All @@ -33,6 +59,8 @@ function handleHover(event: MouseEvent): void {

function hide(): void {
currentId = undefined;
currentFavoriteButton = undefined;
pointerInsideTooltip = false;
tooltip.host.style.display = 'none';
}

Expand All @@ -57,10 +85,37 @@ function render({ entry }: Match): void {
const { panel } = tooltip;
panel.replaceChildren();

const header = document.createElement('div');
header.className = 'tw-header';

const head = document.createElement('div');
head.className = 'tw-head';
head.textContent = entry.term;
panel.append(head);

const favoriteButton = document.createElement('button');
favoriteButton.type = 'button';
favoriteButton.className = 'tw-favorite';
updateFavoriteButton(favoriteButton, entry.id);
favoriteButton.addEventListener('click', async (event) => {
event.preventDefault();
event.stopPropagation();
favoriteButton.disabled = true;

try {
const isFavorite = await toggleFavorite(entry.id);
if (isFavorite) favoriteIds.add(entry.id);
else favoriteIds.delete(entry.id);
updateFavoriteButton(favoriteButton, entry.id);
} catch (error: unknown) {
console.error('TechTerm: お気に入りを更新できませんでした。', error);
} finally {
favoriteButton.disabled = false;
}
});
currentFavoriteButton = favoriteButton;

header.append(head, favoriteButton);
panel.append(header);

const short = document.createElement('div');
short.className = 'tw-short';
Expand Down Expand Up @@ -104,7 +159,16 @@ function createTooltip(): { host: HTMLElement; panel: HTMLElement } {
box-shadow: 0 6px 20px rgba(0,0,0,.35);
font: 13px/1.6 -apple-system, "Hiragino Sans", "Noto Sans JP", sans-serif;
}
.tw-head { font-weight: 700; margin-bottom: 2px; }
.tw-header { display: flex; align-items: center; gap: 10px; margin-bottom: 2px; }
.tw-head { flex: 1; min-width: 0; font-weight: 700; }
.tw-favorite {
display: inline-grid; place-items: center; width: 30px; height: 30px; padding: 0;
border: 1px solid #596276; border-radius: 7px; background: #2b3241; color: #ffd166;
cursor: pointer; font: 20px/1 sans-serif;
}
.tw-favorite:hover { background: #394256; }
.tw-favorite:focus-visible { outline: 2px solid #7fb2ff; outline-offset: 2px; }
.tw-favorite:disabled { cursor: wait; opacity: .65; }
.tw-short { color: #f2f2f2; }
.tw-detail { margin-top: 6px; color: #b9c0cf; }
.tw-example {
Expand All @@ -122,6 +186,21 @@ function createTooltip(): { host: HTMLElement; panel: HTMLElement } {
return { host, panel };
}

function updateFavoriteButton(button: HTMLButtonElement, id: string): void {
const isFavorite = favoriteIds.has(id);
const label = isFavorite ? 'お気に入りから削除' : 'お気に入りに追加';
button.textContent = isFavorite ? '★' : '☆';
button.setAttribute('aria-pressed', String(isFavorite));
button.setAttribute('aria-label', label);
button.title = label;
}

function refreshCurrentFavoriteButton(): void {
if (currentId && currentFavoriteButton) {
updateFavoriteButton(currentFavoriteButton, currentId);
}
}

/** Chrome (caretRangeFromPoint) と Firefox (caretPositionFromPoint) の差を吸収する。 */
function caretFromPoint(x: number, y: number): { node: Node; offset: number } | undefined {
const doc = document as Document & {
Expand Down
Loading