Skip to content

Commit 75a5449

Browse files
author
YutaSakuma-hash
committed
feat: お気に入り機能とサイドパネルを追加
1 parent b50ec57 commit 75a5449

11 files changed

Lines changed: 491 additions & 4 deletions

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ npm run build -w @techword/browser
3939
`chrome://extensions` → デベロッパーモード ON → 「パッケージ化されていない拡張機能を読み込む」
4040
`packages/browser/dist` を選択。任意のページで用語にホバーします。
4141

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

4448
`packages/core/src/data/terms.json` に追記します。

package-lock.json

Lines changed: 38 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/browser/manifest.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,18 @@
22
"manifest_version": 3,
33
"name": "TechTerm",
44
"version": "0.1.1",
5+
"minimum_chrome_version": "114",
56
"description": "プログラミング用語にホバーすると、意味を簡潔に表示します。",
7+
"permissions": ["storage", "sidePanel"],
8+
"action": {
9+
"default_title": "TechTermのお気に入りを開く"
10+
},
11+
"background": {
12+
"service_worker": "background.js"
13+
},
14+
"side_panel": {
15+
"default_path": "sidepanel.html"
16+
},
617
"content_scripts": [
718
{
819
"matches": [

packages/browser/package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,13 @@
44
"private": true,
55
"typescript": "^6.0.3",
66
"scripts": {
7-
"build": "esbuild src/content.ts --bundle --outfile=dist/content.js --format=iife --target=chrome110 && cp manifest.json dist/manifest.json",
8-
"watch": "esbuild src/content.ts --bundle --outfile=dist/content.js --format=iife --target=chrome110 --watch"
7+
"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/",
8+
"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"
99
},
1010
"dependencies": {
1111
"@techword/core": "*"
12+
},
13+
"devDependencies": {
14+
"@types/chrome": "^0.2.9"
1215
}
1316
}

packages/browser/sidepanel.css

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
:root {
2+
color-scheme: light dark;
3+
font-family: -apple-system, BlinkMacSystemFont, "Hiragino Sans", "Noto Sans JP", sans-serif;
4+
background: Canvas;
5+
color: CanvasText;
6+
}
7+
8+
* {
9+
box-sizing: border-box;
10+
}
11+
12+
body {
13+
margin: 0;
14+
}
15+
16+
main {
17+
padding: 18px 16px 28px;
18+
}
19+
20+
header {
21+
display: flex;
22+
align-items: center;
23+
justify-content: space-between;
24+
gap: 16px;
25+
margin-bottom: 18px;
26+
}
27+
28+
h1,
29+
h2,
30+
p {
31+
margin: 0;
32+
}
33+
34+
h1 {
35+
font-size: 20px;
36+
line-height: 1.3;
37+
}
38+
39+
.count {
40+
margin-top: 3px;
41+
color: GrayText;
42+
font-size: 12px;
43+
}
44+
45+
button {
46+
border: 1px solid color-mix(in srgb, CanvasText 24%, transparent);
47+
border-radius: 7px;
48+
background: ButtonFace;
49+
color: ButtonText;
50+
cursor: pointer;
51+
font: inherit;
52+
}
53+
54+
button:hover:not(:disabled) {
55+
background: color-mix(in srgb, ButtonFace 80%, CanvasText 20%);
56+
}
57+
58+
button:focus-visible,
59+
a:focus-visible {
60+
outline: 2px solid Highlight;
61+
outline-offset: 2px;
62+
}
63+
64+
button:disabled {
65+
cursor: default;
66+
opacity: 0.5;
67+
}
68+
69+
.clear-favorites {
70+
flex: none;
71+
padding: 7px 10px;
72+
font-size: 12px;
73+
}
74+
75+
.favorites {
76+
display: grid;
77+
gap: 10px;
78+
margin: 0;
79+
padding: 0;
80+
list-style: none;
81+
}
82+
83+
.favorite-item {
84+
display: flex;
85+
align-items: flex-start;
86+
gap: 12px;
87+
padding: 12px;
88+
border: 1px solid color-mix(in srgb, CanvasText 18%, transparent);
89+
border-radius: 9px;
90+
}
91+
92+
.favorite-body {
93+
flex: 1;
94+
min-width: 0;
95+
}
96+
97+
.favorite-body h2 {
98+
font-size: 15px;
99+
line-height: 1.4;
100+
}
101+
102+
.favorite-body p {
103+
margin-top: 4px;
104+
color: color-mix(in srgb, CanvasText 76%, transparent);
105+
font-size: 13px;
106+
line-height: 1.6;
107+
}
108+
109+
.favorite-body a {
110+
display: inline-block;
111+
margin-top: 6px;
112+
color: LinkText;
113+
font-size: 12px;
114+
}
115+
116+
.remove-favorite {
117+
flex: none;
118+
padding: 6px 8px;
119+
color: #c62828;
120+
font-size: 12px;
121+
}
122+
123+
.empty {
124+
padding: 30px 8px;
125+
color: GrayText;
126+
text-align: center;
127+
font-size: 13px;
128+
}
129+
130+
.error {
131+
margin-bottom: 12px;
132+
color: #c62828;
133+
font-size: 13px;
134+
}

packages/browser/sidepanel.html

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<!doctype html>
2+
<html lang="ja">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1" />
6+
<title>TechTerm お気に入り</title>
7+
<link rel="stylesheet" href="sidepanel.css" />
8+
</head>
9+
<body>
10+
<main>
11+
<header>
12+
<div>
13+
<h1>お気に入り</h1>
14+
<p id="count" class="count" aria-live="polite">0件</p>
15+
</div>
16+
<button id="clear-favorites" class="clear-favorites" type="button" disabled>
17+
すべて削除
18+
</button>
19+
</header>
20+
21+
<p id="error" class="error" role="alert" hidden></p>
22+
<p id="empty" class="empty">お気に入りはまだありません。</p>
23+
<ul id="favorites" class="favorites" aria-label="お気に入りの用語" hidden></ul>
24+
</main>
25+
<script src="sidepanel.js"></script>
26+
</body>
27+
</html>

packages/browser/src/background.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
chrome.sidePanel
2+
.setPanelBehavior({ openPanelOnActionClick: true })
3+
.catch((error: unknown) => console.error('TechTerm: サイドパネルを設定できませんでした。', error));

packages/browser/src/content.ts

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Dictionary, builtinTerms, type Match } from '@techword/core';
2+
import { getFavoriteIds, toggleFavorite, watchFavoriteIds } from './favorites';
23

34
const dictionary = new Dictionary(builtinTerms);
45

@@ -8,14 +9,39 @@ const HOVER_DELAY_MS = 250;
89
const tooltip = createTooltip();
910
let hoverTimer: number | undefined;
1011
let currentId: string | undefined;
12+
let currentFavoriteButton: HTMLButtonElement | undefined;
13+
let favoriteIds = new Set<string>();
14+
let pointerInsideTooltip = false;
15+
16+
void getFavoriteIds()
17+
.then((ids) => {
18+
favoriteIds = new Set(ids);
19+
refreshCurrentFavoriteButton();
20+
})
21+
.catch((error: unknown) => console.error('TechTerm: お気に入りを読み込めませんでした。', error));
22+
23+
watchFavoriteIds((ids) => {
24+
favoriteIds = new Set(ids);
25+
refreshCurrentFavoriteButton();
26+
});
1127

1228
document.addEventListener('mousemove', (event) => {
1329
window.clearTimeout(hoverTimer);
30+
if (event.composedPath().includes(tooltip.host) || pointerInsideTooltip) return;
1431
hoverTimer = window.setTimeout(() => handleHover(event), HOVER_DELAY_MS);
1532
});
1633
document.addEventListener('scroll', hide, { passive: true, capture: true });
1734
window.addEventListener('blur', hide);
1835

36+
tooltip.host.addEventListener('pointerenter', () => {
37+
pointerInsideTooltip = true;
38+
window.clearTimeout(hoverTimer);
39+
});
40+
tooltip.host.addEventListener('pointerleave', () => {
41+
pointerInsideTooltip = false;
42+
hide();
43+
});
44+
1945
function handleHover(event: MouseEvent): void {
2046
const caret = caretFromPoint(event.clientX, event.clientY);
2147
if (!caret || caret.node.nodeType !== Node.TEXT_NODE) return hide();
@@ -33,6 +59,8 @@ function handleHover(event: MouseEvent): void {
3359

3460
function hide(): void {
3561
currentId = undefined;
62+
currentFavoriteButton = undefined;
63+
pointerInsideTooltip = false;
3664
tooltip.host.style.display = 'none';
3765
}
3866

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

88+
const header = document.createElement('div');
89+
header.className = 'tw-header';
90+
6091
const head = document.createElement('div');
6192
head.className = 'tw-head';
6293
head.textContent = entry.term;
63-
panel.append(head);
94+
95+
const favoriteButton = document.createElement('button');
96+
favoriteButton.type = 'button';
97+
favoriteButton.className = 'tw-favorite';
98+
updateFavoriteButton(favoriteButton, entry.id);
99+
favoriteButton.addEventListener('click', async (event) => {
100+
event.preventDefault();
101+
event.stopPropagation();
102+
favoriteButton.disabled = true;
103+
104+
try {
105+
const isFavorite = await toggleFavorite(entry.id);
106+
if (isFavorite) favoriteIds.add(entry.id);
107+
else favoriteIds.delete(entry.id);
108+
updateFavoriteButton(favoriteButton, entry.id);
109+
} catch (error: unknown) {
110+
console.error('TechTerm: お気に入りを更新できませんでした。', error);
111+
} finally {
112+
favoriteButton.disabled = false;
113+
}
114+
});
115+
currentFavoriteButton = favoriteButton;
116+
117+
header.append(head, favoriteButton);
118+
panel.append(header);
64119

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

189+
function updateFavoriteButton(button: HTMLButtonElement, id: string): void {
190+
const isFavorite = favoriteIds.has(id);
191+
const label = isFavorite ? 'お気に入りから削除' : 'お気に入りに追加';
192+
button.textContent = isFavorite ? '★' : '☆';
193+
button.setAttribute('aria-pressed', String(isFavorite));
194+
button.setAttribute('aria-label', label);
195+
button.title = label;
196+
}
197+
198+
function refreshCurrentFavoriteButton(): void {
199+
if (currentId && currentFavoriteButton) {
200+
updateFavoriteButton(currentFavoriteButton, currentId);
201+
}
202+
}
203+
125204
/** Chrome (caretRangeFromPoint) と Firefox (caretPositionFromPoint) の差を吸収する。 */
126205
function caretFromPoint(x: number, y: number): { node: Node; offset: number } | undefined {
127206
const doc = document as Document & {

0 commit comments

Comments
 (0)