PageSourceSearch

https://agrihub-app.herokuapp.com/assets/controllers/speech-input_…e5e0b0ea605ad1a7f68961d74ffbb5a77a40d736.js

js agrihub-app.herokuapp.com collected 2026-10-03 14:47:43 UTC 21,682 bytes, 488 lines download raw bytes

1import { Controller } from "@hotwired/stimulus"
2import { isSpeechRecognitionSupported, startSpeechRecognition } from "lib/speech"
3import { toHalfWidthNumberText } from "lib/speech_number"
4
5const OVERLAY_MARGIN = 4
6// 既存の文字クリアボタン (right: 0.6rem) と揃えた右端からの距離
7const BUTTON_RIGHT_INSET = 8
8const ERROR_STATUS_DURATION = 5000
9// マイクボタンへ少しでも重なる要素を拾うために、ボタンの幅を等分して探る回数
10const OVERLAP_PROBE_COUNT = 5
11// 数量や単価などの数字欄。読み上げの表記ゆれを半角数字へ直してから入れる
12const NUMBER_INPUT_TYPES = ["number", "tel"]
13// 指でスクロールできる領域の overflow 指定
14const SCROLLABLE_OVERFLOWS = ["auto", "scroll"]
15// 検索バーやダイアログのように、ページの中身とは別に画面へ貼り付いて動く position 指定
16const PINNED_POSITIONS = ["fixed", "sticky"]
17// 数字を 1 文字ずつ足していく電卓ボタン (components/number_input_buttons)
18const NUMBER_INPUT_BUTTONS_SELECTOR = ".components_easy_number_input_buttons"
19
20// アプリの WebView にだけ注入される音声入力ブリッジがあるとき、
21// 画面上のすべてのテキスト入力欄へマイクボタンを重ねて表示する。
22// body に接続して DOM の変化を監視するため、
23// Turbo などで動的に挿入された入力欄にもフォーカス操作なしでボタンが付く。
24export default class extends Controller {
25  static targets = ["overlay", "buttonTemplate", "status"]
26
27  connect() {
28    this.session = null
29    this.statusInput = null
30    // ステータスはマイクボタンと同じ受け皿へ移して使い回す。
31    // 受け皿ごと消えても付け直せるよう、target ではなく要素の参照を持ち続ける
32    this.statusElement = this.statusTarget
33    this.hideStatusTimeout = null
34    this.buttonsByInput = new Map()
35    this.shiftedOverlaysByInput = new Map()
36    this.overlaysByHost = new Map()
37    this.syncScheduled = false
38    this.handleSync = this.scheduleSync.bind(this)
39    window.addEventListener("agrihubAppSpeechReady", this.handleSync)
40    // スクロールで入力欄が見え隠れするので、表示の切り替えのために capture で拾う
41    window.addEventListener("scroll", this.handleSync, { capture: true, passive: true })
42    window.addEventListener("resize", this.handleSync)
43    this.mutationObserver = new MutationObserver((records) => {
44      // 自分が管理するボタンの追加・移動で再同期し続けないようにする
45      if (records.every((record) => this.ownOverlayElement(record.target))) return
46
47      this.scheduleSync()
48    })
49    this.mutationObserver.observe(this.element, {
50      childList: true,
51      subtree: true,
52      attributes: true,
53      attributeFilter: ["class", "style", "disabled", "readonly", "type", "hidden"]
54    })
55    this.scheduleSync()
56  }
57
58  disconnect() {
59    this.mutationObserver.disconnect()
60    window.removeEventListener("agrihubAppSpeechReady", this.handleSync)
61    window.removeEventListener("scroll", this.handleSync, { capture: true })
62    window.removeEventListener("resize", this.handleSync)
63    if (this.session != null) this.cancelSession()
64  }
65
66  scheduleSync() {
67    if (this.syncScheduled) return
68
69    this.syncScheduled = true
70    requestAnimationFrame(() => {
71      this.syncScheduled = false
72      this.sync()
73    })
74  }
75
76  sync() {
77    if (!isSpeechRecognitionSupported()) return
78
79    const eligibleInputs = new Set(
80      [...this.element.querySelectorAll("textarea, input[type='text'], input[type='search'], input[type='number'], input[type='tel']")]
81        .filter((input) => this.eligibleInput(input))
82    )
83
84    for (const [input, button] of this.buttonsByInput) {
85      if (!eligibleInputs.has(input)) {
86        this.restoreShiftedOverlays(input)
87        button.remove()
88        this.buttonsByInput.delete(input)
89        this.shiftedOverlaysByInput.delete(input)
90      }
91    }
92    for (const host of this.overlaysByHost.keys()) {
93      if (!host.isConnected) this.overlaysByHost.delete(host)
94    }
95    for (const input of eligibleInputs) this.placeButton(input)
96
97    if (this.statusInput != null && !this.statusInput.isConnected) this.hideStatus()
98
99    // 認識中の入力欄が画面ごと消えたときは認識を破棄する
100    if (this.session != null && !this.session.cancelled && !this.session.input.isConnected) this.cancelSession()
101  }
102
103  eligibleInput(input) {
104    if (input.readOnly || input.disabled || "speechInputSkip" in input.dataset) return false
105
106    // Android の音声認識は長文に向かないため、メモなどの複数行欄にはマイクを出さない
107    return !(this.androidPlatform() && input.tagName === "TEXTAREA")
108  }
109
110  androidPlatform() {
111    return this.element.dataset.platform === "android"
112  }
113
114  placeButton(input) {
115    let button = this.buttonsByInput.get(input)
116    const inputRect = this.visibleRect(input)
117
118    if (inputRect == null) {
119      if (button != null) {
120        button.classList.add("d-none")
121        this.restoreShiftedOverlays(input)
122      }
123      return
124    }
125
126    if (button == null) {
127      button = this.buttonTemplateTarget.content.firstElementChild.cloneNode(true)
128      button.dataset.speechInputFor = input.id || input.name
129      this.buttonsByInput.set(input, button)
130      this.shiftedOverlaysByInput.set(input, new Map())
131    }
132    const overlay = this.overlayFor(input)
133    if (button.parentElement !== overlay) overlay.append(button)
134    button.classList.remove("d-none")
135
136    const buttonSize = button.offsetHeight
137    // 複数行の欄は書き出しの邪魔にならない右下へ、1 行の欄は右端の中央へ重ねる
138    const buttonTop = input.tagName === "TEXTAREA"
139      ? inputRect.bottom - buttonSize - OVERLAY_MARGIN
140      : inputRect.top + (inputRect.height - buttonSize) / 2
141    // 受け皿は入力欄と一緒に動くので、受け皿から見た位置で置けばスクロール中もずれない
142    const overlayRect = overlay.getBoundingClientRect()
143    button.style.top = `${buttonTop - overlayRect.top}px`
144    button.style.left = `${inputRect.right - BUTTON_RIGHT_INSET - button.offsetWidth - overlayRect.left}px`
145    this.shiftOverlappingElementsLeft(input, inputRect, button, buttonTop, buttonSize)
146  }
147
148  // 入力欄と同じ動き方をする要素ごとに受け皿を用意する
149  overlayFor(input) {
150    const host = this.overlayHostFor(input)
151    if (host == null) return this.overlayTarget
152
153    const overlay = this.overlaysByHost.get(host)
154    if (overlay != null && overlay.isConnected) return overlay
155
156    const newOverlay = document.createElement("div")
157    // スクロールする領域では中身と一緒に動く必要があるので、受け皿も中身として流し込む
158    const scrollerModifier = this.scrollableElement(host) ? " speech-input-overlay--scroller" : ""
159    newOverlay.className = `speech-input-overlay${scrollerModifier} d-print-none`
160    host.append(newOverlay)
161    this.overlaysByHost.set(host, newOverlay)
162    return newOverlay
163  }
164
165  // 入力欄と一緒に動く一番内側の要素。ページの中身と一緒に動くだけのときは null を返す
166  overlayHostFor(input) {
167    for (let element = input.parentElement; element !== this.element; element = element.parentElement) {
168      if (this.scrollableElement(element) || this.pinnedElement(element)) return element
169    }
170    return null
171  }
172
173  scrollableElement(element) {
174    const { overflowX, overflowY } = getComputedStyle(element)
175    return SCROLLABLE_OVERFLOWS.includes(overflowX) || SCROLLABLE_OVERFLOWS.includes(overflowY)
176  }
177
178  // ページの中身が流れても画面へ貼り付いたまま動く要素かどうか
179  pinnedElement(element) {
180    return PINNED_POSITIONS.includes(getComputedStyle(element).position)
181  }
182
183  ownOverlayElement(element) {
184    return element.closest(".speech-input-overlay")
185  }
186
187  // マイクボタンは常に入力欄の右端に置き、単位表示や文字クリアボタンなど右端に重なっている別の要素はマイクの左隣へずらす
188  shiftOverlappingElementsLeft(input, inputRect, button, buttonTop, buttonSize) {
189    const buttonLeft = inputRect.right - BUTTON_RIGHT_INSET - button.offsetWidth
190    const buttonRight = inputRect.right - BUTTON_RIGHT_INSET
191    const probeY = buttonTop + buttonSize / 2
192    const shiftedOverlays = this.shiftedOverlaysByInput.get(input)
193
194    // マイクボタン自身に当たらないよう、判定中だけ通り抜けるようにする
195    button.style.pointerEvents = "none"
196    try {
197      for (const overlayElement of this.elementsOverButton(input, buttonLeft, buttonRight, probeY)) {
198        // position: absolute + right 指定の要素だけが対象。それ以外は動かせないので諦める
199        const overlayStyle = getComputedStyle(overlayElement)
200        if (overlayStyle.position !== "absolute") continue
201
202        const overlayShift = overlayElement.getBoundingClientRect().right - (buttonLeft - OVERLAY_MARGIN)
203        if (overlayShift <= 0) continue
204
205        // マイクボタンが消えたときに元へ戻せるよう、初めてずらす要素の指定を控えておく
206        if (!shiftedOverlays.has(overlayElement)) shiftedOverlays.set(overlayElement, overlayElement.style.right)
207
208        overlayElement.style.right = `${parseFloat(overlayStyle.right) + overlayShift}px`
209      }
210    } finally {
211      button.style.pointerEvents = ""
212    }
213  }
214
215  // マイクボタンの上に描かれている要素を、ボタンの幅を等分した位置で探して集める。
216  // 単位表示 (.unit-over-input) のようにボタンの左端だけへ食い込む細い要素も取りこぼさない
217  elementsOverButton(input, buttonLeft, buttonRight, probeY) {
218    const overlayElements = new Set()
219    // 左端へ食い込んだだけの要素も拾えるよう、ボタンの左端から右端までを等間隔に探る
220    const probeStep = (buttonRight - buttonLeft - 1) / (OVERLAP_PROBE_COUNT - 1)
221
222    for (let probeIndex = 0; probeIndex < OVERLAP_PROBE_COUNT; probeIndex += 1) {
223      for (const hitElement of document.elementsFromPoint(buttonLeft + 0.5 + probeStep * probeIndex, probeY)) {
224        // 入力欄より後ろに描かれている要素は重なっていないので、そこで打ち切る
225        if (hitElement === input || hitElement.contains(input)) break
226        if (this.ownOverlayElement(hitElement)) continue
227
228        // アイコンなどの中身に当たったときは、その親のボタン全体をずらす
229        overlayElements.add(hitElement.closest("button, a, [role='button']") ?? hitElement)
230      }
231    }
232    return overlayElements
233  }
234
235  // ずらした要素を元の指定へ戻す。戻す対象が無いときは書き込まないので、
236  // style の変化を拾う MutationObserver との間で同期を繰り返さない
237  restoreShiftedOverlays(input) {
238    const shiftedOverlays = this.shiftedOverlaysByInput.get(input)
239    for (const [overlayElement, originalRight] of shiftedOverlays) overlayElement.style.right = originalRight
240    shiftedOverlays.clear()
241  }
242
243  // ステータスをマイクボタンと同じ受け皿へ入れて、入力欄の右下へ置く。
244  // 受け皿が入力欄と一緒に動くので、スクロール中に位置を計算し直さなくてもずれない
245  placeStatus(input) {
246    // 認識中に欄が編集できなくなるとマイクボタンは外されるため、受け皿はボタンからではなく入力欄から決める
247    const overlay = this.overlayFor(input)
248    if (this.statusElement.parentElement !== overlay) overlay.append(this.statusElement)
249
250    const inputRect = input.getBoundingClientRect()
251    const overlayRect = overlay.getBoundingClientRect()
252    this.statusElement.style.top = `${inputRect.bottom + OVERLAY_MARGIN - overlayRect.top}px`
253    this.statusElement.style.right = `${overlayRect.right - inputRect.right}px`
254  }
255
256  // 画面内に見えている入力欄の位置を返す。ダイアログなどの下に隠れている欄には null を返す
257  visibleRect(input) {
258    const rect = input.getBoundingClientRect()
259    if (rect.width === 0 || rect.height === 0) return null
260    if (rect.bottom < 0 || rect.top > window.innerHeight || rect.right < 0 || rect.left > window.innerWidth) return null
261
262    const centerY = rect.top + rect.height / 2
263    const probePoints = [
264      [rect.left + 2, centerY],
265      [rect.left + rect.width / 2, centerY]
266    ]
267    const uncovered = probePoints.some(([x, y]) => {
268      const hitElement = document.elementFromPoint(x, y)
269      return hitElement === input || input.contains(hitElement)
270    })
271    return uncovered ? rect : null
272  }
273
274  // ボタンのタップで入力欄のフォーカスとカーソル位置を失わないようにする
275  keepFocus(event) {
276    event.preventDefault()
277  }
278
279  toggle(event) {
280    const button = event.currentTarget
281    if (this.session?.button === button) {
282      this.stop()
283      return
284    }
285
286    this.start(this.inputFor(button), button)
287  }
288
289  inputFor(button) {
290    for (const [input, candidate] of this.buttonsByInput) {
291      if (candidate === button) return input
292    }
293  }
294
295  start(input, button) {
296    // 別の欄で認識中なら、その内容を確定させてから新しい欄で開始する。
297    // 確定した全文は認識の終わりに届くため、前の欄への反映はそのセッションの onEnd に任せる
298    if (this.session != null) {
299      this.stopRecognition(this.session)
300      this.setIdleState(this.session.button)
301      this.session = null
302    }
303
304    // 認識中に別の欄へ移っても、途中経過を開始した欄へ反映し続けるためにセッションへ束ねる
305    const numberInput = this.numberOnlyInput(input)
306    const session = {
307      input,
308      button,
309      numberInput,
310      originalValue: input.value,
311      // 数字欄は言い直しのたびに欄ごと入れ替えるため、挿入位置を持たない
312      insertionRange: numberInput ? null : this.insertionRangeFor(input)
313    }
314    this.session = session
315    const isCurrentSession = () => this.session === session
316
317    this.setListeningState(button)
318    this.showStatus(input, "音声入力を開始しています…")
319
320    try {
321      session.recognition = startSpeechRecognition({
322        locale: "ja-JP",
323        onStart: () => { if (isCurrentSession()) this.showStatus(input, "音声を入力しています…") },
324        onTranscript: (transcript) => this.insertTranscript(session, transcript),
325        onError: (message) => { if (isCurrentSession()) this.showStatus(input, message, true) },
326        // 別の欄へ移ったあとに終わったときも、入れ終わった欄の計算は動かす
327        onEnd: () => {
328          this.commitValue(session)
329          if (isCurrentSession()) this.finishListening()
330        }
331      })
332    } catch {
333      this.finishListening()
334      this.showStatus(input, "音声入力を開始できませんでした", true)
335    }
336  }
337
338  // 停止を伝えてから表示を変える。表示より先に止めておけば、認識だけが動き続けて次の開始を拒まれることがない
339  stop() {
340    this.stopRecognition(this.session)
341    this.session.button.disabled = true
342    this.showStatus(this.session.input, "音声入力を終了しています…")
343  }
344
345  // 音声入力中に画面の他の場所をタップしたら、そこで入力を確定して終わる。
346  // 停止ボタンを押し忘れても、按分などの入力を終えてから動く計算が置いていかれないようにする
347  stopOnOutsideTap(event) {
348    if (this.session == null) return
349    // マイクボタンのタップは toggle が扱う
350    if (this.ownOverlayElement(event.target)) return
351    // 認識中の欄そのもののタップは、カーソルを動かしただけなので続ける
352    if (event.target === this.session.input) return
353
354    this.stop()
355  }
356
357  // アプリは停止を二度伝えられると次の開始を拒むため、停止は一度だけ伝える
358  stopRecognition(session) {
359    if (session.stopping) return
360
361    session.stopping = true
362    session.recognition.stop()
363  }
364
365  cancelSession() {
366    this.session.cancelled = true
367    this.session.recognition.cancel()
368  }
369
370  finishListening() {
371    const session = this.session
372    this.setIdleState(session.button)
373    this.session = null
374
375    // 途中経過では数字になるのを待つため、最後まで数字にならなかったときにだけ知らせる。
376    // 画面ごと消えた欄を破棄したときは、知らせる場所が無いので
376出さない
377    if (session.numberNotRecognized && !session.cancelled) this.showStatus(session.input, "数字を聞き取れませんでした", true)
378  }
379
380  // 値を代入してもブラウザは change を出さないため、認識の終わりに自分で送り、
381  // 合計作業時間の再計算や収穫時の入力欄の切り替えなど、欄に登録済みの change 処理へ引き継ぐ。
382  // 途中経過のたびに送ると収穫チェックの通信を繰り返すので、認識ごとに一度だけ送る。
383  // 別の欄へ移ったあとで前の欄の確定結果が届いたときも連動が取り残されないよう、セッション単位で行う
384  commitValue(session) {
385    if (session.cancelled || session.input.value === session.originalValue) return
386
387    session.input.dispatchEvent(new Event("change", { bubbles: true }))
388  }
389
390  insertTranscript(session, transcript) {
391    if (session.numberInput) {
392      this.insertNumber(session, transcript)
393      return
394    }
395
396    const prefix = session.originalValue.slice(0, session.insertionRange.start)
397    const suffix = session.originalValue.slice(session.insertionRange.end)
398    session.input.value = `${prefix}${transcript}${suffix}`
399
400    const caretPosition = prefix.length + transcript.length
401    session.input.setSelectionRange(caretPosition, caretPosition)
402    this.dispatchInput(session.input)
403  }
404
405  // 検索や再計算を動かしている各画面は、手入力を keyup で拾っている。
406  // 音声で入れた文字も手で打ったときと同じように扱われるよう、input と合わせて keyup も知らせる
407  dispatchInput(input) {
408    input.dispatchEvent(new Event("input", { bubbles: true }))
409    input.dispatchEvent(new Event("keyup", { bubbles: true }))
410  }
411
412  // 数字だけを入れる欄かどうか。
413  // 小数を打ち終わるまで値を保てるよう type='text' にしてある欄も、
414  // 電卓ボタンが値を入れる先になっているなら数字だけの欄として扱う
415  numberOnlyInput(input) {
416    if (NUMBER_INPUT_TYPES.includes(input.type)) return true
417
418    return [...this.element.querySelectorAll(NUMBER_INPUT_BUTTONS_SELECTOR)].some((numberInputButtons) => (
419      input.matches(numberInputButtons.getAttribute("target_name")) &&
420        numberInputButtons.closest(numberInputButtons.getAttribute("parents_name")).contains(input)
421    ))
422  }
423
424  // 数字欄は言い直しで値を上書きしていくため、追記ではなく欄全体を認識した数値へ置き換える
425  insertNumber(session, transcript) {
426    const numberText = toHalfWidthNumberText(transcript)
427    session.numberNotRecognized = numberText == null
428    if (numberText == null) return
429
430    session.input.value = numberText
431    this.dispatchInput(session.input)
432  }
433
434  insertionRangeFor(input) {
435    // フォーカスせずワンタップで開始したときは末尾へ追記する
436    if (document.activeElement !== input) return { start: input.value.length, end: input.value.length }
437
438    return { start: input.selectionStart, end: input.selectionEnd }
439  }
440
441  setListeningState(button) {
442    button.disabled = false
443    button.classList.add("speech-input-button--listening")
444    button.setAttribute("aria-label", "音声入力を停止")
445    button.setAttribute("aria-pressed", "true")
446    const icon = button.querySelector("i")
447    icon.classList.remove("fa-microphone")
448    icon.classList.add("fa-stop")
449  }
450
451  setIdleState(button) {
452    button.disabled = false
453    button.classList.remove("speech-input-button--listening")
454    button.setAttribute("aria-label", "音声入力を開始")
455    button.setAttribute("aria-pressed", "false")
456    const icon = button.querySelector("i")
457    icon.classList.remove("fa-stop")
458    icon.classList.add("fa-microphone")
459
460    if (!this.statusElement.classList.contains("text-danger")) this.hideStatus()
461  }
462
463  showStatus(input, message, error = false) {
464    // 欄が画面から消えたあとに認識の終了や停止が届くことがある。
465    // 置き場所になる受け皿を欄から辿れないので、知らせは出さずに片付ける
466    if (!input.isConnected) {
467      this.hideStatus()
468      return
469    }
470
471    clearTimeout(this.hideStatusTimeout)
472    this.statusInput = input
473    this.statusElement.textContent = message
474    this.statusElement.classList.toggle("text-danger", error)
475    this.statusElement.classList.toggle("text-secondary", !error)
476    this.statusElement.classList.remove("d-none")
477    this.placeStatus(input)
478
479    // エラーは読める時間だけ出して自動で消す(常時表示のボタンの周りに残り続けないようにする)
480    if (error) this.hideStatusTimeout = setTimeout(() => this.hideStatus(), ERROR_STATUS_DURATION)
481  }
482
483  hideStatus() {
484    clearTimeout(this.hideStatusTimeout)
485    this.statusInput = null
486    this.statusElement.classList.add("d-none")
487  }
488};

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.