From 75a5449175d377bce73a74ee5536987fc98d3e7e Mon Sep 17 00:00:00 2001
From: YutaSakuma-hash <81286@g.ecc.u-tokyo.ac.jp>
Date: Thu, 10 Sep 2026 11:39:00 +0900
Subject: [PATCH] =?UTF-8?q?feat:=20=E3=81=8A=E6=B0=97=E3=81=AB=E5=85=A5?=
=?UTF-8?q?=E3=82=8A=E6=A9=9F=E8=83=BD=E3=81=A8=E3=82=B5=E3=82=A4=E3=83=89?=
=?UTF-8?q?=E3=83=91=E3=83=8D=E3=83=AB=E3=82=92=E8=BF=BD=E5=8A=A0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.md | 4 +
package-lock.json | 38 ++++++++
packages/browser/manifest.json | 11 +++
packages/browser/package.json | 7 +-
packages/browser/sidepanel.css | 134 +++++++++++++++++++++++++++++
packages/browser/sidepanel.html | 27 ++++++
packages/browser/src/background.ts | 3 +
packages/browser/src/content.ts | 83 +++++++++++++++++-
packages/browser/src/favorites.ts | 56 ++++++++++++
packages/browser/src/sidepanel.ts | 119 +++++++++++++++++++++++++
packages/browser/tsconfig.json | 13 +++
11 files changed, 491 insertions(+), 4 deletions(-)
create mode 100644 packages/browser/sidepanel.css
create mode 100644 packages/browser/sidepanel.html
create mode 100644 packages/browser/src/background.ts
create mode 100644 packages/browser/src/favorites.ts
create mode 100644 packages/browser/src/sidepanel.ts
create mode 100644 packages/browser/tsconfig.json
diff --git a/README.md b/README.md
index 9acecd0..88b4c37 100644
--- a/README.md
+++ b/README.md
@@ -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` に追記します。
diff --git a/package-lock.json b/package-lock.json
index 9e02975..25332c8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -452,6 +452,41 @@
"resolved": "packages/core",
"link": true
},
+ "node_modules/@types/chrome": {
+ "version": "0.2.9",
+ "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.2.9.tgz",
+ "integrity": "sha512-6rXrDPkkWv4D2Q23HqT+iED4voODU5CpOSc2VFgKyecSFItuEsIBvz9CY6jUQ3dd+Q7zo1bnCE9pKRMtkYut5g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/filesystem": "*",
+ "@types/har-format": "*"
+ }
+ },
+ "node_modules/@types/filesystem": {
+ "version": "0.0.36",
+ "resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz",
+ "integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/filewriter": "*"
+ }
+ },
+ "node_modules/@types/filewriter": {
+ "version": "0.0.33",
+ "resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.33.tgz",
+ "integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/har-format": {
+ "version": "1.2.16",
+ "resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz",
+ "integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/node": {
"version": "22.20.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
@@ -1116,6 +1151,9 @@
"version": "0.1.1",
"dependencies": {
"@techword/core": "*"
+ },
+ "devDependencies": {
+ "@types/chrome": "^0.2.9"
}
},
"packages/core": {
diff --git a/packages/browser/manifest.json b/packages/browser/manifest.json
index 1e0a37d..dddea6b 100644
--- a/packages/browser/manifest.json
+++ b/packages/browser/manifest.json
@@ -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": [
diff --git a/packages/browser/package.json b/packages/browser/package.json
index 865c587..69b8c64 100644
--- a/packages/browser/package.json
+++ b/packages/browser/package.json
@@ -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"
}
}
diff --git a/packages/browser/sidepanel.css b/packages/browser/sidepanel.css
new file mode 100644
index 0000000..e78f374
--- /dev/null
+++ b/packages/browser/sidepanel.css
@@ -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;
+}
diff --git a/packages/browser/sidepanel.html b/packages/browser/sidepanel.html
new file mode 100644
index 0000000..a83d89c
--- /dev/null
+++ b/packages/browser/sidepanel.html
@@ -0,0 +1,27 @@
+
+
+
+
+
+ TechTerm お気に入り
+
+
+
+
+
+
+
+ お気に入りはまだありません。
+
+
+
+
+
diff --git a/packages/browser/src/background.ts b/packages/browser/src/background.ts
new file mode 100644
index 0000000..0d9113c
--- /dev/null
+++ b/packages/browser/src/background.ts
@@ -0,0 +1,3 @@
+chrome.sidePanel
+ .setPanelBehavior({ openPanelOnActionClick: true })
+ .catch((error: unknown) => console.error('TechTerm: サイドパネルを設定できませんでした。', error));
diff --git a/packages/browser/src/content.ts b/packages/browser/src/content.ts
index e08afb2..9cc4f7b 100644
--- a/packages/browser/src/content.ts
+++ b/packages/browser/src/content.ts
@@ -1,4 +1,5 @@
import { Dictionary, builtinTerms, type Match } from '@techword/core';
+import { getFavoriteIds, toggleFavorite, watchFavoriteIds } from './favorites';
const dictionary = new Dictionary(builtinTerms);
@@ -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();
+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();
@@ -33,6 +59,8 @@ function handleHover(event: MouseEvent): void {
function hide(): void {
currentId = undefined;
+ currentFavoriteButton = undefined;
+ pointerInsideTooltip = false;
tooltip.host.style.display = 'none';
}
@@ -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';
@@ -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 {
@@ -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 & {
diff --git a/packages/browser/src/favorites.ts b/packages/browser/src/favorites.ts
new file mode 100644
index 0000000..891451b
--- /dev/null
+++ b/packages/browser/src/favorites.ts
@@ -0,0 +1,56 @@
+export const FAVORITES_STORAGE_KEY = 'favoriteTermIds';
+
+type FavoriteIdsListener = (ids: string[]) => void;
+
+function sanitizeFavoriteIds(value: unknown): string[] {
+ if (!Array.isArray(value)) return [];
+ return [
+ ...new Set(value.filter((id): id is string => typeof id === 'string' && id.length > 0)),
+ ];
+}
+
+export async function getFavoriteIds(): Promise {
+ const result = await chrome.storage.sync.get(FAVORITES_STORAGE_KEY);
+ return sanitizeFavoriteIds(result[FAVORITES_STORAGE_KEY]);
+}
+
+async function setFavoriteIds(ids: string[]): Promise {
+ await chrome.storage.sync.set({
+ [FAVORITES_STORAGE_KEY]: sanitizeFavoriteIds(ids),
+ });
+}
+
+export async function toggleFavorite(id: string): Promise {
+ const ids = await getFavoriteIds();
+ const nextIsFavorite = !ids.includes(id);
+ const nextIds = nextIsFavorite
+ ? [...ids, id]
+ : ids.filter((currentId) => currentId !== id);
+
+ await setFavoriteIds(nextIds);
+ return nextIsFavorite;
+}
+
+export async function removeFavorite(id: string): Promise {
+ const ids = await getFavoriteIds();
+ await setFavoriteIds(ids.filter((currentId) => currentId !== id));
+}
+
+export async function clearFavorites(): Promise {
+ await setFavoriteIds([]);
+}
+
+export function watchFavoriteIds(listener: FavoriteIdsListener): () => void {
+ const handleChange = (
+ changes: Record,
+ areaName: string,
+ ): void => {
+ if (areaName !== 'sync') return;
+ const change = changes[FAVORITES_STORAGE_KEY];
+ if (!change) return;
+ listener(sanitizeFavoriteIds(change.newValue));
+ };
+
+ chrome.storage.onChanged.addListener(handleChange);
+ return () => chrome.storage.onChanged.removeListener(handleChange);
+}
diff --git a/packages/browser/src/sidepanel.ts b/packages/browser/src/sidepanel.ts
new file mode 100644
index 0000000..36cd84f
--- /dev/null
+++ b/packages/browser/src/sidepanel.ts
@@ -0,0 +1,119 @@
+import { builtinTerms, type TermEntry } from '@techword/core';
+import {
+ clearFavorites,
+ getFavoriteIds,
+ removeFavorite,
+ watchFavoriteIds,
+} from './favorites';
+
+const favoriteList = requireElement('favorites');
+const emptyMessage = requireElement('empty');
+const count = requireElement('count');
+const clearButton = requireElement('clear-favorites');
+const errorMessage = requireElement('error');
+const termsById = new Map(builtinTerms.map((entry) => [entry.id, entry]));
+
+let currentFavoriteIds: string[] = [];
+
+clearButton.addEventListener('click', async () => {
+ if (currentFavoriteIds.length === 0) return;
+ if (!window.confirm('お気に入りをすべて削除しますか?')) return;
+
+ clearButton.disabled = true;
+ hideError();
+ try {
+ await clearFavorites();
+ renderFavorites([]);
+ } catch (error: unknown) {
+ showError('お気に入りを削除できませんでした。', error);
+ clearButton.disabled = false;
+ }
+});
+
+watchFavoriteIds(renderFavorites);
+void loadFavorites();
+
+async function loadFavorites(): Promise {
+ hideError();
+ try {
+ renderFavorites(await getFavoriteIds());
+ } catch (error: unknown) {
+ renderFavorites([]);
+ showError('お気に入りを読み込めませんでした。', error);
+ }
+}
+
+function renderFavorites(ids: string[]): void {
+ currentFavoriteIds = ids;
+ const entries = ids
+ .map((id) => termsById.get(id))
+ .filter((entry): entry is TermEntry => entry !== undefined);
+
+ favoriteList.replaceChildren(...entries.map(createFavoriteItem));
+ favoriteList.hidden = entries.length === 0;
+ emptyMessage.hidden = entries.length !== 0;
+ count.textContent = `${entries.length}件`;
+ clearButton.disabled = ids.length === 0;
+}
+
+function createFavoriteItem(entry: TermEntry): HTMLLIElement {
+ const item = document.createElement('li');
+ item.className = 'favorite-item';
+
+ const body = document.createElement('div');
+ body.className = 'favorite-body';
+
+ const term = document.createElement('h2');
+ term.textContent = entry.term;
+
+ const description = document.createElement('p');
+ description.textContent = entry.short;
+
+ body.append(term, description);
+
+ if (entry.link) {
+ const link = document.createElement('a');
+ link.href = entry.link;
+ link.target = '_blank';
+ link.rel = 'noreferrer noopener';
+ link.textContent = '詳しく';
+ body.append(link);
+ }
+
+ const removeButton = document.createElement('button');
+ removeButton.type = 'button';
+ removeButton.className = 'remove-favorite';
+ removeButton.textContent = '削除';
+ removeButton.setAttribute('aria-label', `${entry.term}をお気に入りから削除`);
+ removeButton.addEventListener('click', async () => {
+ removeButton.disabled = true;
+ hideError();
+ try {
+ await removeFavorite(entry.id);
+ renderFavorites(currentFavoriteIds.filter((id) => id !== entry.id));
+ } catch (error: unknown) {
+ showError(`${entry.term}を削除できませんでした。`, error);
+ removeButton.disabled = false;
+ }
+ });
+
+ item.append(body, removeButton);
+ return item;
+}
+
+function requireElement(id: string): T {
+ const element = document.getElementById(id);
+ if (!element) throw new Error(`Missing element: #${id}`);
+ return element as T;
+}
+
+function hideError(): void {
+ errorMessage.hidden = true;
+ errorMessage.textContent = '';
+}
+
+function showError(message: string, error: unknown): void {
+ console.error(`TechTerm: ${message}`, error);
+ errorMessage.textContent = message;
+ errorMessage.hidden = false;
+}
diff --git a/packages/browser/tsconfig.json b/packages/browser/tsconfig.json
new file mode 100644
index 0000000..e7b5bb3
--- /dev/null
+++ b/packages/browser/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "lib": ["ES2022", "DOM"],
+ "types": ["chrome"],
+ "strict": true,
+ "noEmit": true,
+ "skipLibCheck": true
+ },
+ "include": ["src/**/*.ts"]
+}