PageSourceSearch

https://littlebirdelectronics.com.au/assets/controllers/books/reader_controller-ca782f44.js

js littlebirdelectronics.com.au collected 2026-09-25 14:08:42 UTC 4,748 bytes, 131 lines download raw bytes

1import { Controller } from "@hotwired/stimulus"
2
3// The book reader: keyboard pager, the in-book search dialog, and the
4// "Ask this book" drawer.
5//
6// The pager deliberately clicks the rendered prev/next links rather than
7// building URLs — the server already worked out where those go, including
8// across section dividers, so there's one source of truth for the reading
9// order.
10export default class extends Controller {
11  static targets = [
12    "previousLink", "nextLink",
13    "searchDialog", "searchInput",
14    "askDialog", "askInput", "askSubmit", "askTranscript"
15  ]
16  static values = { askUrl: String }
17
18  connect() {
19    this.onKeydown = this.handleKeydown.bind(this)
20    document.addEventListener("keydown", this.onKeydown)
21    this.history = []
22  }
23
24  disconnect() {
25    document.removeEventListener("keydown", this.onKeydown)
26  }
27
28  handleKeydown(event) {
29    // Never hijack a key the reader is using to type.
30    if (event.metaKey || event.ctrlKey || event.altKey) return
31    const tag = event.target.tagName
32    if (tag === "INPUT" || tag === "TEXTAREA" || event.target.isContentEditable) return
33
34    switch (event.key) {
35      case "ArrowRight":
36        if (this.hasNextLinkTarget) this.nextLinkTarget.click()
37        break
38      case "ArrowLeft":
39        if (this.hasPreviousLinkTarget) this.previousLinkTarget.click()
40        break
41      case "/":
42        event.preventDefault()
43        this.openSearch()
44        break
45    }
46  }
47
48  // ── Search ────────────────────────────────────────────────────────────────
49  openSearch() {
50    if (!this.hasSearchDialogTarget) return
51    this.searchDialogTarget.showModal()
52    if (this.hasSearchInputTarget) this.searchInputTarget.focus()
53  }
54
55  closeSearch() {
56    if (this.hasSearchDialogTarget) this.searchDialogTarget.close()
57  }
58
59  // ── Ask this book ─────────────────────────────────────────────────────────
60  openAsk() {
61    if (!this.hasAskDialogTarget) return
62    this.askDialogTarget.showModal()
63    if (this.hasAskInputTarget) this.askInputTarget.focus()
64  }
65
66  closeAsk() {
67    if (this.hasAskDialogTarget) this.askDialogTarget.close()
68  }
69
70  async submitAsk(event) {
71    event.preventDefault()
72    const question = this.askInputTarget.value.trim()
73    if (!question) return
74
75    this.appendMessage("you", question)
76    this.askInputTarget.value = ""
77    this.askSubmitTarget.disabled = true
78    const thinking = this.appendMessage("book", "Reading…")
79
80    try {
81      const response = await fetch(this.askUrlValue, {
82        method: "POST",
83        headers: {
84          "Content-Type": "application/json",
85          "Accept": "application/json",
86          "X-CSRF-Token": this.csrfToken
87        },
88        body: JSON.stringify({ question: question, history: this.history })
89      })
90      const data = await response.json()
91      const reply = data.reply || data.error || "Something went wrong."
92      thinking.querySelector("[data-body]").textContent = reply
93
94      if (data.ok) {
95        this.history.push({ role: "reader", text: question })
96        this.history.push({ role: "book", text: reply })
97        // Only the last few turns are worth sending back — the book itself is
98        // the context that matters, and a long transcript just crowds it out.
99        this.history = this.history.slice(-8)
100      }
101    } catch (error) {
102      thinking.querySelector("[data-body]").textContent =
103        "I couldn't reach the book expert just then. Try again in a moment."
104    } finally {
105      this.askSubmitTarget.disabled = false
106      this.askTranscriptTarget.scrollTop = this.askTranscriptTarget.scrollHeight
107      this.askInputTarget.focus()
108    }
109  }
110
111  appendMessage(role, text) {
112    const wrapper = document.createElement("div")
113    wrapper.className = role === "you" ? "text-right" : ""
114
115    const bubble = document.createElement("div")
116    bubble.setAttribute("data-body", "")
117    bubble.className = role === "you"
118      ? "inline-block max-w-[85%] rounded-2xl rounded-br-sm bg-brand-600 px-4 py-2 text-left text-white whitespace-pre-wrap"
119      : "inline-block max-w-[95%] rounded-2xl rounded-bl-sm bg-gray-100 px-4 py-2 text-gray-900 whitespace-pre-wrap dark:bg-gray-800 dark:text-gray-100"
120    bubble.textContent = text
121
122    wrapper.appendChild(bubble)
123    this.askTranscriptTarget.appendChild(wrapper)
124    this.askTranscriptTarget.scrollTop = this.askTranscriptTarget.scrollHeight
125    return wrapper
126  }
127
128  get csrfToken() {
129    return document.querySelector("meta[name='csrf-token']")?.content || ""
130  }
131}

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.