|
| 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 | +} |
0 commit comments