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 お気に入り + + + +
+
+
+

お気に入り

+

0件

+
+ +
+ + +

お気に入りはまだありません。

+ +
+ + + 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"] +}