11import { Dictionary , builtinTerms , type Match } from '@techword/core' ;
2+ import { getFavoriteIds , toggleFavorite , watchFavoriteIds } from './favorites' ;
23
34const dictionary = new Dictionary ( builtinTerms ) ;
45
@@ -8,14 +9,39 @@ const HOVER_DELAY_MS = 250;
89const tooltip = createTooltip ( ) ;
910let hoverTimer : number | undefined ;
1011let 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
1228document . 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} ) ;
1633document . addEventListener ( 'scroll' , hide , { passive : true , capture : true } ) ;
1734window . 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+
1945function 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
3460function 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) の差を吸収する。 */
126205function caretFromPoint ( x : number , y : number ) : { node : Node ; offset : number } | undefined {
127206 const doc = document as Document & {
0 commit comments