Skip to content

Commit 407dc4f

Browse files
authored
feat(toolify): 全ツールで結果 permalink を有効化 (#5)
?s= による結果共有はこれまで useShareableState を自前で呼ぶ 7 本だけの 対応だった。残り 216 本に個別の restore コールバックを書くのは現実的でなく、 実装差異による取りこぼしも避けられないため、DOM を総なめして直列化する 汎用フックを ToolInteractionTracker と同じ「バブリングを1箇所で拾う」方式で 追加する。ShareBar は既に ?s= を監視しているので無変更で追従する。 設計上の要点: - 構造キー(type + 同種内連番)。素の通し番号だと mode で表示本数が変わる ツール 35 本で復元先が 1 つずれ、別の欄に値が入る。 - 2 パス復元。select/radio/モードボタンを先に当て、rAF で現れた欄を埋める。 - ネイティブ setter。React 19 の制御コンポーネントは el.value 直接代入を 無視する(value tracker が「変化なし」と判断し onChange が出ない)。 - モード切替が button のツール向けに、選択中(bg-brand-600)の index を b キーで保存し復元時に click し直す。 自前実装の 7 本は MANUAL_SHARE_SLUGS で除外し、?s= の二重書き込みを防ぐ。 リストが腐らないよう、実際に useShareableState を import しているツールと 一致することをテストで固定する。 検証: typecheck clean / vitest 26/26 / CDP e2e 10/10 / ランダム 30 本スイープ。 Claude-Session: https://claude.ai/code/session_015fXFezC8ABkuZga4497Jw2 Co-authored-by: appdevelopsk <appdevelopsk@users.noreply.github.com>
1 parent db807f4 commit 407dc4f

5 files changed

Lines changed: 295 additions & 1 deletion

File tree

site/src/components/tools/ToolFrame.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { FavoriteButton } from "@/components/tools/FavoriteButton";
1212
import { RecentTracker } from "@/components/tools/RecentTracker";
1313
import { siteConfig } from "@/lib/config";
1414
import { CATEGORY_CONFIG } from "@/lib/tools/categories";
15+
import { MANUAL_SHARE_SLUGS } from "@/lib/tools/manual-share";
1516
import type { ToolMeta } from "@/lib/tools/types";
1617

1718
interface Props {
@@ -56,7 +57,12 @@ export function ToolFrame({ meta, title, description, related, children, article
5657
押し出されていた(個別ツール着地の engaged≒0% の構造要因)。
5758
まず道具を見せ、広告は結果の下に置く(2026-08-22)。 */}
5859
<section className="mt-6 rounded-lg border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-900 sm:p-6">
59-
<ToolInteractionTracker slug={meta.slug} locale={locale} category={meta.category}>
60+
<ToolInteractionTracker
61+
slug={meta.slug}
62+
locale={locale}
63+
category={meta.category}
64+
shareable={!MANUAL_SHARE_SLUGS.has(meta.slug)}
65+
>
6066
{children}
6167
</ToolInteractionTracker>
6268
</section>

site/src/components/tools/ToolInteractionTracker.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { useEffect, useRef, type ReactNode } from "react";
44
import { trackCalculate, trackCopyResult, trackPresetClick } from "@/lib/analytics/events";
5+
import { useAutoShareableState } from "@/lib/hooks/useAutoShareableState";
56

67
/**
78
* ツール本体を包み、GA4 の calculate / copy_result / preset_click を一括計装する。
@@ -25,13 +26,19 @@ export function ToolInteractionTracker({
2526
slug,
2627
locale,
2728
category,
29+
shareable = true,
2830
children,
2931
}: {
3032
slug: string;
3133
/** ページのロケール。GA4 custom dimension `locale` に載せる。 */
3234
locale?: string;
3335
/** ToolMeta.category。GA4 custom dimension `category` に載せる。 */
3436
category?: string;
37+
/**
38+
* 入力値を ?s= に載せる汎用共有リンクを有効にするか。
39+
* ツール自身が useShareableState を持つ場合は false(二重書き込み回避)。
40+
*/
41+
shareable?: boolean;
3542
children: ReactNode;
3643
}) {
3744
const ref = useRef<HTMLDivElement>(null);
@@ -92,6 +99,8 @@ export function ToolInteractionTracker({
9299
};
93100
}, [slug, locale, category]);
94101

102+
useAutoShareableState(ref, slug, shareable);
103+
95104
return <div ref={ref}>{children}</div>;
96105
}
97106

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
"use client";
2+
3+
import { useEffect, type RefObject } from "react";
4+
5+
/**
6+
* ツール本体の DOM 入力を丸ごと ?s= に載せ、共有リンクから復元する。
7+
*
8+
* なぜ DOM 総なめ方式か: useShareableState は (slug, value, restore) を
9+
* ツール側が手書きする契約で、223本中7本しか採用されていない。残り216本に
10+
* 個別の restore コールバックを書くのは非現実的で、実装差異による取りこぼしも
11+
* 避けられない。入力の実体はどれも DOM 上の input/select/textarea なので、
12+
* ToolInteractionTracker と同じく「バブリングを1箇所で拾う」方が網羅的。
13+
*
14+
* キーは「出現位置」ではなく構造キー(type + 同種内の連番)。理由は
15+
* triangle-calculator のように mode によって表示される input 本数が変わる
16+
* ツールが35本あり、素の通し番号だと復元先が1つずれて別の項目に値が入るため。
17+
* 構造キーなら、表示中のフィールド群が変わっても同種フィールドの対応が保たれる。
18+
*
19+
* 復元は2パスで行う。select(モード切替)を先に当ててから、それによって
20+
* 新しく現れた input を次のフレームで埋める。1パスだと「sss モードの共有リンクを
21+
* right モードの初期表示に流し込む」ことになり、値が入らない/誤った欄に入る。
22+
*
23+
* React 19 の制御コンポーネントは el.value への直接代入を無視する(内部の
24+
* value tracker が「変化なし」と判断して onChange を出さない)ため、
25+
* ネイティブ setter で書いてから input/change を dispatch する。
26+
*
27+
* モード切替が <select> ではなく <button> のツールが14本ある(triangle-calculator の
28+
* right/sss/sas 等)。ボタンの選択状態は React state にしか無く DOM の値にならないため、
29+
* 「選択中は bg-brand-600 が付く」という当リポジトリ共通の見た目規約を手掛かりに、
30+
* 押されているボタンの index を `b` として保存し、復元時に click し直す。
31+
* これが無いと「sss で共有したリンクが right のまま開き、3辺の値が2欄に入る」事故になる。
32+
* 規約が当てはまらないツールでは `b` が空になり、従来通り入力値だけが復元される
33+
* (モードは既定のまま = 壊れるのではなく、共有範囲が狭まるだけ)。
34+
*
35+
* 値は端末を離れない。URL に載るのはユーザーが自分で共有した時だけ。
36+
*/
37+
38+
/** 選択中を表す配色クラス。ToolFrame 配下のツール本体に限って探す。 */
39+
const ACTIVE_CLASS = "bg-brand-600";
40+
41+
/** 直列化・復元の対象にするフィールドだけを返す。file は復元不能なので除く。 */
42+
function fields(root: HTMLElement): HTMLElement[] {
43+
return Array.from(
44+
root.querySelectorAll<HTMLElement>("input, select, textarea"),
45+
).filter((el) => {
46+
if (el instanceof HTMLInputElement) {
47+
// file は値をプログラムから復元できない。button/submit は状態を持たない。
48+
return !["file", "button", "submit", "reset", "image"].includes(el.type);
49+
}
50+
return true;
51+
});
52+
}
53+
54+
/**
55+
* 構造キー: `${種別}${同種内の連番}`。
56+
* 表示本数が変わっても同種フィールド同士の対応が崩れにくい。
57+
*/
58+
function keyOf(el: HTMLElement, seen: Map<string, number>): string {
59+
let kind: string;
60+
if (el instanceof HTMLInputElement) kind = el.type || "text";
61+
else if (el instanceof HTMLSelectElement) kind = "select";
62+
else kind = "textarea";
63+
// radio は name でグループ化されるので、グループ単位のキーにする。
64+
if (el instanceof HTMLInputElement && el.type === "radio" && el.name) {
65+
kind = `radio:${el.name}`;
66+
}
67+
const n = seen.get(kind) ?? 0;
68+
seen.set(kind, n + 1);
69+
return `${kind}${n}`;
70+
}
71+
72+
function readValue(el: HTMLElement): string | boolean | null {
73+
if (el instanceof HTMLInputElement) {
74+
if (el.type === "checkbox") return el.checked;
75+
if (el.type === "radio") return el.checked ? el.value : null;
76+
return el.value;
77+
}
78+
if (el instanceof HTMLSelectElement) return el.value;
79+
if (el instanceof HTMLTextAreaElement) return el.value;
80+
return null;
81+
}
82+
83+
/** React の value tracker を迂回して値を入れ、onChange を発火させる。 */
84+
function writeValue(el: HTMLElement, v: string | boolean): void {
85+
if (el instanceof HTMLInputElement && (el.type === "checkbox" || el.type === "radio")) {
86+
const next = typeof v === "boolean" ? v : el.value === v;
87+
if (el.checked === next) return;
88+
el.click(); // checked 系は click が最も確実に React の状態へ伝わる
89+
return;
90+
}
91+
const str = typeof v === "boolean" ? String(v) : v;
92+
if ((el as HTMLInputElement).value === str) return;
93+
const proto =
94+
el instanceof HTMLTextAreaElement
95+
? HTMLTextAreaElement.prototype
96+
: el instanceof HTMLSelectElement
97+
? HTMLSelectElement.prototype
98+
: HTMLInputElement.prototype;
99+
const setter = Object.getOwnPropertyDescriptor(proto, "value")?.set;
100+
setter?.call(el, str);
101+
el.dispatchEvent(new Event("input", { bubbles: true }));
102+
el.dispatchEvent(new Event("change", { bubbles: true }));
103+
}
104+
105+
function encodeState(value: object): string {
106+
const json = JSON.stringify(value);
107+
const bytes = new TextEncoder().encode(json);
108+
let bin = "";
109+
for (const b of bytes) bin += String.fromCharCode(b);
110+
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
111+
}
112+
113+
function decodeState(raw: string): unknown {
114+
const b64 = raw.replace(/-/g, "+").replace(/_/g, "/");
115+
const bin = atob(b64);
116+
const bytes = new Uint8Array(bin.length);
117+
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
118+
return JSON.parse(new TextDecoder().decode(bytes));
119+
}
120+
121+
/** ツール本体内のボタン。選択中(bg-brand-600)の index を後で復元する。 */
122+
function buttons(root: HTMLElement): HTMLButtonElement[] {
123+
return Array.from(root.querySelectorAll<HTMLButtonElement>("button"));
124+
}
125+
126+
/** 現在の DOM 状態を構造キー→値のオブジェクトに落とす。 */
127+
function snapshot(root: HTMLElement): Record<string, string | boolean> {
128+
const seen = new Map<string, number>();
129+
const out: Record<string, string | boolean> = {};
130+
for (const el of fields(root)) {
131+
const k = keyOf(el, seen);
132+
const v = readValue(el);
133+
if (v === null) continue;
134+
out[k] = v;
135+
}
136+
// 選択中モードボタンの index 一覧(カンマ区切り)。該当が無ければキー自体を作らない。
137+
const active = buttons(root)
138+
.map((b, i) => (b.className.includes(ACTIVE_CLASS) ? i : -1))
139+
.filter((i) => i >= 0);
140+
if (active.length > 0) out.b = active.join(",");
141+
return out;
142+
}
143+
144+
/** URL が長くなりすぎる共有リンクは作らない(ブラウザ/SNS が切る)。 */
145+
const MAX_ENCODED = 1800;
146+
147+
export function useAutoShareableState(
148+
ref: RefObject<HTMLElement | null>,
149+
slug: string,
150+
/** ツール自身が useShareableState を使っている場合は false にして二重書き込みを避ける。 */
151+
enabled: boolean,
152+
): void {
153+
useEffect(() => {
154+
const root = ref.current;
155+
if (!root || !enabled) return;
156+
157+
// --- 復元 (2パス) ---
158+
let restoring = true;
159+
let raf: number | undefined;
160+
try {
161+
const raw = new URLSearchParams(window.location.search).get("s");
162+
if (raw) {
163+
const parsed = decodeState(raw);
164+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
165+
const saved = parsed as Record<string, string | boolean>;
166+
const apply = (only?: (el: HTMLElement) => boolean) => {
167+
const seen = new Map<string, number>();
168+
for (const el of fields(root)) {
169+
const k = keyOf(el, seen);
170+
const v = saved[k];
171+
// `k in saved` では index 型が絞れないので値を取って判定する。
172+
if (v === undefined) continue;
173+
if (only && !only(el)) continue;
174+
writeValue(el, v);
175+
}
176+
};
177+
// 1st: モード切替(select / radio / ボタン)を先に当てる。
178+
apply((el) => el instanceof HTMLSelectElement || (el instanceof HTMLInputElement && el.type === "radio"));
179+
// ボタン式モードは index を click し直す。既に選択中なら押さない
180+
// (トグル実装のツールで二度押しになり元に戻るのを防ぐ)。
181+
const b = saved.b;
182+
if (typeof b === "string" && b.length > 0) {
183+
const all = buttons(root);
184+
for (const raw of b.split(",")) {
185+
const i = Number(raw);
186+
const btn = all[i];
187+
if (btn && !btn.className.includes(ACTIVE_CLASS)) btn.click();
188+
}
189+
}
190+
// 2nd: 切替で現れたフィールドを含めて全体を当てる。
191+
raf = requestAnimationFrame(() => {
192+
apply();
193+
restoring = false;
194+
});
195+
} else {
196+
restoring = false;
197+
}
198+
} else {
199+
restoring = false;
200+
}
201+
} catch {
202+
restoring = false; // 壊れた ?s= は無視して既定値のまま動かす
203+
}
204+
205+
// --- 記録 ---
206+
// 入力のたびに URL を書く。replaceState なので「戻る」は壊れない。
207+
let timer: number | undefined;
208+
const onChange = () => {
209+
if (restoring) return; // 復元由来の合成イベントで URL を書かない
210+
window.clearTimeout(timer);
211+
timer = window.setTimeout(() => {
212+
try {
213+
const encoded = encodeState(snapshot(root));
214+
if (encoded.length > MAX_ENCODED) return;
215+
const url = new URL(window.location.href);
216+
url.searchParams.set("s", encoded);
217+
window.history.replaceState(null, "", url.toString());
218+
} catch {
219+
/* 共有できないだけで計算は続行 */
220+
}
221+
}, 250);
222+
};
223+
224+
root.addEventListener("input", onChange);
225+
root.addEventListener("change", onChange);
226+
return () => {
227+
if (raf !== undefined) cancelAnimationFrame(raf);
228+
window.clearTimeout(timer);
229+
root.removeEventListener("input", onChange);
230+
root.removeEventListener("change", onChange);
231+
};
232+
}, [ref, slug, enabled]);
233+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { describe, it, expect } from "vitest";
2+
import fs from "node:fs";
3+
import path from "node:path";
4+
import { MANUAL_SHARE_SLUGS } from "./manual-share";
5+
6+
const TOOLS_DIR = path.resolve(__dirname, "../../tools");
7+
8+
/** 実ソース上で useShareableState を import しているツール slug。 */
9+
function actualAdopters(): string[] {
10+
return fs
11+
.readdirSync(TOOLS_DIR, { withFileTypes: true })
12+
.filter((d) => d.isDirectory() && !d.name.startsWith("_"))
13+
.filter((d) => {
14+
const f = path.join(TOOLS_DIR, d.name, "Component.tsx");
15+
return fs.existsSync(f) && /useShareableState/.test(fs.readFileSync(f, "utf8"));
16+
})
17+
.map((d) => d.name)
18+
.sort();
19+
}
20+
21+
describe("MANUAL_SHARE_SLUGS", () => {
22+
// 汎用の useAutoShareableState と自前の useShareableState が同じ ?s= を
23+
// 奪い合うと共有リンクが壊れる。この一覧のズレはその事故に直結する。
24+
it("matches the tools that actually import useShareableState", () => {
25+
expect([...MANUAL_SHARE_SLUGS].sort()).toEqual(actualAdopters());
26+
});
27+
});

site/src/lib/tools/manual-share.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* 自前で useShareableState を持つツールの slug。
3+
*
4+
* これらは ToolInteractionTracker の汎用 ?s= 書き込み(useAutoShareableState)を
5+
* 無効にする。両方が history.replaceState で同じ ?s= を奪い合うと、
6+
* 後勝ちで片方の形式が壊れるため。
7+
*
8+
* 一覧は手書きだが、registry.test.ts で実ソースと突き合わせて検証している
9+
* (新たに useShareableState を採用したツールを足し忘れると失敗する)。
10+
*/
11+
export const MANUAL_SHARE_SLUGS: ReadonlySet<string> = new Set([
12+
"body-fat-calculator",
13+
"bmi-calculator",
14+
"bmr-calculator",
15+
"calorie-calculator",
16+
"one-rep-max-calculator",
17+
"pace-calculator",
18+
"water-intake-calculator",
19+
]);

0 commit comments

Comments
 (0)