zenn-embed-elements 0.4.9-alpha.0 → 0.4.9-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2 @@
1
+ export declare function initFootnoteTooltip(): void;
2
+ export declare function _resetFootnoteTooltipStateForTest(): void;
@@ -0,0 +1,235 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.initFootnoteTooltip = initFootnoteTooltip;
4
+ exports._resetFootnoteTooltipStateForTest = _resetFootnoteTooltipStateForTest;
5
+ const TOOLTIP_ID = 'zenn-footnote-tooltip';
6
+ const SHOW_DELAY_MS = 150;
7
+ const HIDE_DELAY_MS = 300;
8
+ let initialized = false;
9
+ let tooltip = null;
10
+ let currentRef = null;
11
+ let showTimerId;
12
+ let hideTimerId;
13
+ function cancelShowTimer() {
14
+ if (showTimerId !== undefined) {
15
+ window.clearTimeout(showTimerId);
16
+ showTimerId = undefined;
17
+ }
18
+ }
19
+ function cancelHideTimer() {
20
+ if (hideTimerId !== undefined) {
21
+ window.clearTimeout(hideTimerId);
22
+ hideTimerId = undefined;
23
+ }
24
+ }
25
+ function getTooltip() {
26
+ // SPA のページ遷移等で body ごと差し替えられた場合は作り直す
27
+ if (tooltip && tooltip.isConnected)
28
+ return tooltip;
29
+ tooltip = document.createElement('div');
30
+ tooltip.id = TOOLTIP_ID;
31
+ // znc クラスを付けることで CSS 変数とコンテンツスタイルを継承する
32
+ tooltip.className = 'znc zenn-footnote-tooltip';
33
+ tooltip.setAttribute('role', 'tooltip');
34
+ tooltip.hidden = true;
35
+ tooltip.addEventListener('mouseenter', () => cancelHideTimer());
36
+ tooltip.addEventListener('mouseleave', () => scheduleHide());
37
+ document.body.appendChild(tooltip);
38
+ return tooltip;
39
+ }
40
+ function findFootnoteRef(target) {
41
+ if (!(target instanceof Element))
42
+ return null;
43
+ const ref = target.closest('sup.footnote-ref > a');
44
+ if (!(ref instanceof HTMLAnchorElement))
45
+ return null;
46
+ // ツールチップ内の入れ子の参照は対象外(ポップアップは連鎖させない)
47
+ if (tooltip && tooltip.contains(ref))
48
+ return null;
49
+ // コンテンツ領域(.znc)内の脚注参照のみ対象にする
50
+ if (!ref.closest('.znc'))
51
+ return null;
52
+ return ref;
53
+ }
54
+ function isHttpUrl(str) {
55
+ try {
56
+ const url = new URL(str);
57
+ return url.protocol === 'http:' || url.protocol === 'https:';
58
+ }
59
+ catch (_a) {
60
+ return false;
61
+ }
62
+ }
63
+ // 埋め込み要素(iframe)はツールチップ内では機能しないため、リンクに置き換える
64
+ function replaceEmbedsWithLinks(fragment, refHref) {
65
+ fragment.querySelectorAll('.embed-block').forEach((embed) => {
66
+ var _a;
67
+ const dataContent = (_a = embed
68
+ .querySelector('iframe')) === null || _a === void 0 ? void 0 : _a.getAttribute('data-content');
69
+ const decoded = (() => {
70
+ if (!dataContent)
71
+ return null;
72
+ try {
73
+ return decodeURIComponent(dataContent);
74
+ }
75
+ catch (_a) {
76
+ return null;
77
+ }
78
+ })();
79
+ const link = document.createElement('a');
80
+ if (decoded && isHttpUrl(decoded)) {
81
+ // card 等の embed-server 系は data-content に元の URL を保持している
82
+ link.href = decoded;
83
+ link.textContent = decoded;
84
+ link.rel = 'noreferrer noopener nofollow';
85
+ link.target = '_blank';
86
+ }
87
+ else {
88
+ // 元の URL を復元できない埋め込みは脚注へのリンクにする
89
+ link.href = refHref;
90
+ link.textContent = '埋め込みコンテンツ';
91
+ }
92
+ embed.replaceWith(link);
93
+ });
94
+ }
95
+ function getFootnoteContent(ref) {
96
+ var _a, _b;
97
+ const id = (() => {
98
+ try {
99
+ return decodeURIComponent(ref.hash.slice(1));
100
+ }
101
+ catch (_a) {
102
+ // 不正なパーセントエンコードを含む hash は対象外
103
+ return null;
104
+ }
105
+ })();
106
+ if (!id)
107
+ return null;
108
+ const item = document.getElementById(id);
109
+ if (!item || !item.classList.contains('footnote-item'))
110
+ return null;
111
+ const fragment = document.createDocumentFragment();
112
+ item.childNodes.forEach((node) => {
113
+ fragment.appendChild(node.cloneNode(true));
114
+ });
115
+ // 「↩︎」戻りリンクはツールチップ内では不要
116
+ fragment.querySelectorAll('.footnote-backref').forEach((el) => el.remove());
117
+ replaceEmbedsWithLinks(fragment, (_a = ref.getAttribute('href')) !== null && _a !== void 0 ? _a : '');
118
+ if (!((_b = fragment.textContent) === null || _b === void 0 ? void 0 : _b.trim()))
119
+ return null;
120
+ return fragment;
121
+ }
122
+ function positionTooltip(tip, refRect) {
123
+ const margin = 8;
124
+ const tipRect = tip.getBoundingClientRect();
125
+ let left = refRect.left + refRect.width / 2 - tipRect.width / 2;
126
+ left = Math.min(Math.max(margin, left), window.innerWidth - tipRect.width - margin);
127
+ // 基本は参照リンクの上側。収まらない場合は下側に反転する
128
+ let top = refRect.top - tipRect.height - margin;
129
+ if (top < margin) {
130
+ top = refRect.bottom + margin;
131
+ }
132
+ // 下側でも収まらない場合はビューポート内にクランプする(参照リンクとの重なりを許容)
133
+ top = Math.max(Math.min(top, window.innerHeight - tipRect.height - margin), margin);
134
+ tip.style.left = `${left + window.scrollX}px`;
135
+ tip.style.top = `${top + window.scrollY}px`;
136
+ }
137
+ function show(ref) {
138
+ const content = getFootnoteContent(ref);
139
+ if (!content)
140
+ return;
141
+ // ツールチップの内容差し替えで ref の位置が影響を受けないよう、先に測っておく
142
+ const refRect = ref.getBoundingClientRect();
143
+ hideNow();
144
+ const tip = getTooltip();
145
+ tip.replaceChildren(content);
146
+ tip.hidden = false;
147
+ positionTooltip(tip, refRect);
148
+ ref.setAttribute('aria-describedby', TOOLTIP_ID);
149
+ currentRef = ref;
150
+ }
151
+ function hideNow() {
152
+ cancelShowTimer();
153
+ cancelHideTimer();
154
+ if (currentRef) {
155
+ currentRef.removeAttribute('aria-describedby');
156
+ currentRef = null;
157
+ }
158
+ if (tooltip) {
159
+ tooltip.hidden = true;
160
+ }
161
+ }
162
+ function scheduleHide() {
163
+ cancelHideTimer();
164
+ hideTimerId = window.setTimeout(hideNow, HIDE_DELAY_MS);
165
+ }
166
+ function onMouseOver(event) {
167
+ const ref = findFootnoteRef(event.target);
168
+ if (!ref)
169
+ return;
170
+ // タッチのみのデバイスでは従来のジャンプ動作のままにする
171
+ if (!window.matchMedia('(hover: hover)').matches)
172
+ return;
173
+ cancelHideTimer();
174
+ if (ref === currentRef)
175
+ return;
176
+ cancelShowTimer();
177
+ showTimerId = window.setTimeout(() => show(ref), SHOW_DELAY_MS);
178
+ }
179
+ function onMouseOut(event) {
180
+ const ref = findFootnoteRef(event.target);
181
+ if (!ref)
182
+ return;
183
+ cancelShowTimer();
184
+ if (ref === currentRef)
185
+ scheduleHide();
186
+ }
187
+ function onFocusIn(event) {
188
+ const ref = findFootnoteRef(event.target);
189
+ if (!ref)
190
+ return;
191
+ cancelHideTimer();
192
+ if (ref === currentRef)
193
+ return;
194
+ // キーボード利用者には遅延なしで表示する
195
+ show(ref);
196
+ }
197
+ function onFocusOut(event) {
198
+ // 参照リンクまたはツールチップ内からのフォーカス喪失のみ対象にする
199
+ const fromRef = findFootnoteRef(event.target) !== null;
200
+ const fromTooltip = event.target instanceof Node && !!tooltip && tooltip.contains(event.target);
201
+ if (!fromRef && !fromTooltip)
202
+ return;
203
+ const next = event.relatedTarget;
204
+ // フォーカス移動先がツールチップ内または参照リンクなら表示を維持する
205
+ if (next instanceof Node && tooltip && tooltip.contains(next))
206
+ return;
207
+ if (findFootnoteRef(next))
208
+ return;
209
+ hideNow();
210
+ }
211
+ function onKeyDown(event) {
212
+ if (event.key !== 'Escape')
213
+ return;
214
+ // 表示遅延中なら表示の予約ごと取り消す
215
+ cancelShowTimer();
216
+ if (tooltip && !tooltip.hidden)
217
+ hideNow();
218
+ }
219
+ function initFootnoteTooltip() {
220
+ if (initialized)
221
+ return;
222
+ if (typeof document === 'undefined')
223
+ return;
224
+ initialized = true;
225
+ document.addEventListener('mouseover', onMouseOver);
226
+ document.addEventListener('mouseout', onMouseOut);
227
+ document.addEventListener('focusin', onFocusIn);
228
+ document.addEventListener('focusout', onFocusOut);
229
+ document.addEventListener('keydown', onKeyDown);
230
+ }
231
+ // テスト用: モジュール内部の状態をリセットする(リスナーの登録は維持される)
232
+ function _resetFootnoteTooltipStateForTest() {
233
+ hideNow();
234
+ tooltip = null;
235
+ }
package/lib/index.js CHANGED
@@ -1,4 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const katex_1 = require("./classes/katex");
4
+ const footnote_tooltip_1 = require("./classes/footnote-tooltip");
4
5
  customElements.define('embed-katex', katex_1.EmbedKatex);
6
+ (0, footnote_tooltip_1.initFootnoteTooltip)();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zenn-embed-elements",
3
- "version": "0.4.9-alpha.0",
3
+ "version": "0.4.9-alpha.1",
4
4
  "license": "MIT",
5
5
  "description": "Web components for embedded contents.",
6
6
  "repository": {
@@ -25,18 +25,20 @@
25
25
  "fix": "run-s fix:*",
26
26
  "fix:eslint": "eslint . --fix",
27
27
  "fix:prettier": "prettier -w .",
28
- "test": "echo 'no test yet'"
28
+ "test": "vitest run"
29
29
  },
30
30
  "devDependencies": {
31
31
  "@eslint/js": "^10.0.1",
32
32
  "@types/node": "^25.4.0",
33
33
  "eslint": "^10.0.0",
34
34
  "eslint-config-prettier": "^10.1.8",
35
+ "jsdom": "^29.1.1",
35
36
  "rimraf": "^6.0.1",
36
37
  "typescript": "^5.9.3",
37
- "typescript-eslint": "^8.46.2"
38
+ "typescript-eslint": "^8.46.2",
39
+ "vitest": "^4.0.6"
38
40
  },
39
- "gitHead": "735c70321b4c2d139f5512dd19de393a1acfb0e0",
41
+ "gitHead": "27acfe07149647d15352fe860a931aa781c3a4c1",
40
42
  "publishConfig": {
41
43
  "access": "public"
42
44
  }