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.