mycowriter 0.1.8 → 0.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4ce17226d9a6500ad92b2bb437ee9d41777c66a0f22408020fd497416341a4a3
|
|
4
|
+
data.tar.gz: 5c3bdcf2489292b81d322c19ef076532a4bd490c955677ef210adb01c376666e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 417e4550be1b8c9a7a260178c59794c0d2d44a046ba7095b9c7ca74f9924c7f35894791371d6f5391aec9872453323d5ccbef8bcb4d6d85e4e353dac1ea5ca72
|
|
7
|
+
data.tar.gz: 5dc2f062785c83d657ebbcc1f547a14da0cb8e61ce0988de46b533eb56197492741b3492f929640ba6efd2b941e2d5d720ca9af7cd134238dba374dc661b8979
|
|
@@ -1,270 +1,280 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus"
|
|
2
2
|
|
|
3
|
-
//
|
|
4
|
-
// Provides
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// Requirements: An autocomplete endpoint that returns [{id, name}] in JSON.
|
|
3
|
+
// Inline Autocomplete Controller for textarea fields
|
|
4
|
+
// Provides genus/species name suggestions while typing in prose
|
|
5
|
+
// Inserts selected text at cursor position
|
|
6
|
+
// Primary use case: Helping users spell genus/species names correctly in articles and mushroom notes
|
|
8
7
|
|
|
9
8
|
export default class extends Controller {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
mushroomId: String,
|
|
17
|
-
kind: String,
|
|
18
|
-
requireUppercase: { type: Boolean, default: true }
|
|
19
|
-
}
|
|
9
|
+
static targets = ["textarea", "dropdown"]
|
|
10
|
+
static values = {
|
|
11
|
+
genusUrl: String,
|
|
12
|
+
speciesUrl: String,
|
|
13
|
+
min: { type: Number, default: 4 }
|
|
14
|
+
}
|
|
20
15
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
16
|
+
connect() {
|
|
17
|
+
this.debounceTimer = null
|
|
18
|
+
this.currentWord = ""
|
|
19
|
+
this.cursorPosition = 0
|
|
20
|
+
this.wordStart = 0
|
|
21
|
+
this.ignoreNextInput = false
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
onInput(event) {
|
|
25
|
+
// Skip if this input was triggered by our own text insertion
|
|
26
|
+
if (this.ignoreNextInput) {
|
|
27
|
+
this.ignoreNextInput = false
|
|
28
|
+
return
|
|
30
29
|
}
|
|
31
30
|
|
|
32
|
-
|
|
33
|
-
clearTimeout(this.debounceTimer)
|
|
34
|
-
let query = this.inputTarget.value.trim()
|
|
31
|
+
clearTimeout(this.debounceTimer)
|
|
35
32
|
|
|
36
|
-
|
|
33
|
+
const textarea = this.textareaTarget
|
|
34
|
+
this.cursorPosition = textarea.selectionStart
|
|
35
|
+
const text = textarea.value
|
|
37
36
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
return
|
|
43
|
-
}
|
|
37
|
+
// Get word at cursor
|
|
38
|
+
const wordInfo = this.getWordAtCursor(text, this.cursorPosition)
|
|
39
|
+
this.currentWord = wordInfo.word
|
|
40
|
+
this.wordStart = wordInfo.start
|
|
44
41
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
42
|
+
// Check if should trigger autocomplete:
|
|
43
|
+
// 1. Uppercase word >= 4 chars (genus name like "Ganoderma")
|
|
44
|
+
// 2. OR lowercase word >= 4 chars AFTER a capitalized word (species epithet like "sessile" after "Ganoderma")
|
|
45
|
+
const isUppercase = /^[A-Z]/.test(this.currentWord)
|
|
46
|
+
const isLowercaseAfterGenus = /^[a-z]/.test(this.currentWord) && this.hasPrecedingCapitalizedWord(text, this.wordStart)
|
|
51
47
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
this.showLoader()
|
|
59
|
-
this.debounceTimer = setTimeout(() => {
|
|
60
|
-
this.autocomplete(query)
|
|
61
|
-
}, 150)
|
|
48
|
+
if (this.currentWord.length >= this.minValue && (isUppercase || isLowercaseAfterGenus)) {
|
|
49
|
+
this.debounceTimer = setTimeout(() => {
|
|
50
|
+
this.fetchSuggestions(this.currentWord)
|
|
51
|
+
}, 150)
|
|
52
|
+
} else {
|
|
53
|
+
this.hideDropdown()
|
|
62
54
|
}
|
|
55
|
+
}
|
|
63
56
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
57
|
+
hasPrecedingCapitalizedWord(text, currentWordStart) {
|
|
58
|
+
// Look backwards from current word to find previous word
|
|
59
|
+
// Check if it starts with capital letter AND is long enough to be a genus name
|
|
60
|
+
// This prevents false matches like "I hope" where "I" is not a genus
|
|
61
|
+
let i = currentWordStart - 1
|
|
68
62
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
fetch(url, { headers: {"Accept": "application/json"} })
|
|
75
|
-
.then(r => r.json())
|
|
76
|
-
.then(items => {
|
|
77
|
-
this.renderDropdown(items)
|
|
78
|
-
})
|
|
79
|
-
.catch(_err => this.hideDropdown())
|
|
80
|
-
.finally(() => this.hideLoader())
|
|
63
|
+
// Skip whitespace backwards
|
|
64
|
+
while (i >= 0 && /\s/.test(text[i])) {
|
|
65
|
+
i--
|
|
81
66
|
}
|
|
82
67
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
this.dropdownTarget.innerHTML = items
|
|
90
|
-
.map(item =>
|
|
91
|
-
`<li class="hover:bg-blue-100 cursor-pointer px-3 py-2"
|
|
92
|
-
data-id="${item.id}"
|
|
93
|
-
data-label="${item.name}">
|
|
94
|
-
${item.name}
|
|
95
|
-
</li>`
|
|
96
|
-
).join("")
|
|
97
|
-
this.dropdownTarget.classList.remove("hidden")
|
|
68
|
+
if (i < 0) return false
|
|
69
|
+
|
|
70
|
+
// Find start of previous word
|
|
71
|
+
let prevWordEnd = i
|
|
72
|
+
while (i >= 0 && /[a-zA-Z]/.test(text[i])) {
|
|
73
|
+
i--
|
|
98
74
|
}
|
|
99
75
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
76
|
+
const prevWord = text.substring(i + 1, prevWordEnd + 1)
|
|
77
|
+
// Genus names are typically 4+ characters (minimum set by gem config)
|
|
78
|
+
// This filters out single letters like "I" or short words like "A", "It"
|
|
79
|
+
return prevWord.length >= this.minValue && /^[A-Z]/.test(prevWord)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
getPreviousWord(text, currentWordStart) {
|
|
83
|
+
// Extract the previous word (genus name) for filtering species
|
|
84
|
+
let i = currentWordStart - 1
|
|
85
|
+
|
|
86
|
+
// Skip whitespace backwards
|
|
87
|
+
while (i >= 0 && /\s/.test(text[i])) {
|
|
88
|
+
i--
|
|
103
89
|
}
|
|
104
90
|
|
|
105
|
-
|
|
106
|
-
|
|
91
|
+
if (i < 0) return ""
|
|
92
|
+
|
|
93
|
+
// Find start of previous word
|
|
94
|
+
let prevWordEnd = i
|
|
95
|
+
while (i >= 0 && /[a-zA-Z]/.test(text[i])) {
|
|
96
|
+
i--
|
|
107
97
|
}
|
|
108
|
-
|
|
109
|
-
|
|
98
|
+
|
|
99
|
+
return text.substring(i + 1, prevWordEnd + 1)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
getWordAtCursor(text, position) {
|
|
103
|
+
// Find word boundaries (letters only, no spaces or punctuation)
|
|
104
|
+
let start = position
|
|
105
|
+
let end = position
|
|
106
|
+
|
|
107
|
+
// Move back to start of word
|
|
108
|
+
while (start > 0 && /[a-zA-Z]/.test(text[start - 1])) {
|
|
109
|
+
start--
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const id = li.dataset.id
|
|
116
|
-
const label = li.dataset.label
|
|
117
|
-
if(this.selected.has(id)) {
|
|
118
|
-
this.hideDropdown()
|
|
119
|
-
this.inputTarget.value = ""
|
|
120
|
-
return
|
|
121
|
-
}
|
|
122
|
-
this.createToken({id, label})
|
|
123
|
-
this.hideDropdown()
|
|
124
|
-
this.inputTarget.value = ""
|
|
125
|
-
this.inputTarget.focus()
|
|
126
|
-
// Save via AJAX
|
|
127
|
-
this.saveToken(id, label)
|
|
112
|
+
// Move forward to end of word
|
|
113
|
+
while (end < text.length && /[a-zA-Z]/.test(text[end])) {
|
|
114
|
+
end++
|
|
128
115
|
}
|
|
129
116
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const pill = document.createElement("span")
|
|
135
|
-
pill.className = "inline-flex items-center bg-gray-200 rounded px-2 py-0.5 text-sm mr-1 mb-1"
|
|
136
|
-
pill.setAttribute("data-token-id", id)
|
|
137
|
-
pill.innerHTML = `
|
|
138
|
-
${label}
|
|
139
|
-
<button type="button" class="ml-1 text-gray-600 hover:text-red-600" aria-label="Remove" data-action="mycowriter--autocomplete#removeToken" data-id="${id}">×</button>
|
|
140
|
-
`
|
|
141
|
-
this.listTarget.appendChild(pill)
|
|
142
|
-
this.updateHiddenIds()
|
|
117
|
+
return {
|
|
118
|
+
word: text.substring(start, end),
|
|
119
|
+
start: start,
|
|
120
|
+
end: end
|
|
143
121
|
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async fetchSuggestions(query) {
|
|
125
|
+
try {
|
|
126
|
+
const textarea = this.textareaTarget
|
|
127
|
+
const text = textarea.value
|
|
128
|
+
const isLowercaseAfterGenus = /^[a-z]/.test(query) && this.hasPrecedingCapitalizedWord(text, this.wordStart)
|
|
129
|
+
|
|
130
|
+
// If lowercase word after a capitalized word, search SPECIES first (likely typing species epithet)
|
|
131
|
+
if (isLowercaseAfterGenus) {
|
|
132
|
+
// Extract the previous genus name to filter species by that genus
|
|
133
|
+
const prevGenus = this.getPreviousWord(text, this.wordStart)
|
|
134
|
+
|
|
135
|
+
const speciesResponse = await fetch(
|
|
136
|
+
`${this.speciesUrlValue}?q=${encodeURIComponent(query)}&genus_name=${encodeURIComponent(prevGenus)}`,
|
|
137
|
+
{ headers: { "Accept": "application/json" } }
|
|
138
|
+
)
|
|
144
139
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
const pill = this.listTarget.querySelector(`[data-token-id="${id}"]`)
|
|
148
|
-
const itemName = pill ? pill.textContent.trim().replace('×', '').trim() : 'this item'
|
|
140
|
+
if (speciesResponse.ok) {
|
|
141
|
+
const speciesData = await speciesResponse.json()
|
|
149
142
|
|
|
150
|
-
|
|
151
|
-
|
|
143
|
+
if (speciesData.length > 0) {
|
|
144
|
+
this.renderDropdown(speciesData)
|
|
152
145
|
return
|
|
146
|
+
}
|
|
153
147
|
}
|
|
154
148
|
|
|
155
|
-
//
|
|
156
|
-
this.
|
|
157
|
-
|
|
158
|
-
this.selected.delete(id)
|
|
159
|
-
if(pill) pill.remove()
|
|
160
|
-
this.updateHiddenIds()
|
|
161
|
-
}, () => {
|
|
162
|
-
// Error callback - pill stays in place
|
|
149
|
+
// No species matches - try genus as fallback
|
|
150
|
+
const genusResponse = await fetch(`${this.genusUrlValue}?q=${encodeURIComponent(query)}`, {
|
|
151
|
+
headers: { "Accept": "application/json" }
|
|
163
152
|
})
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
updateHiddenIds() {
|
|
167
|
-
if(this.hasHiddenIdsTarget) {
|
|
168
|
-
this.hiddenIdsTarget.value = Array.from(this.selected.keys()).join(",")
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
153
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
route = `/genus_mushrooms.json`
|
|
180
|
-
body = JSON.stringify({genus_mushroom: {mushroom_id: mushroomId, genus_id: id}})
|
|
181
|
-
} else if (kind=="species") {
|
|
182
|
-
route = `/mushroom_species.json`
|
|
183
|
-
body = JSON.stringify({mushroom_species: {mushroom_id: mushroomId, species_id: id}})
|
|
184
|
-
} else if (kind=="trees") {
|
|
185
|
-
route = `/mushroom_trees.json`
|
|
186
|
-
body = JSON.stringify({mushroom_tree: {mushroom_id: mushroomId, tree_id: id}})
|
|
187
|
-
} else if (kind=="plants") {
|
|
188
|
-
route = `/mushroom_plants.json`
|
|
189
|
-
body = JSON.stringify({mushroom_plant: {mushroom_id: mushroomId, plant_id: id}})
|
|
154
|
+
if (genusResponse.ok) {
|
|
155
|
+
const genusData = await genusResponse.json()
|
|
156
|
+
if (genusData.length > 0) {
|
|
157
|
+
this.renderDropdown(genusData)
|
|
158
|
+
} else {
|
|
159
|
+
this.hideDropdown()
|
|
160
|
+
}
|
|
190
161
|
} else {
|
|
191
|
-
|
|
162
|
+
this.hideDropdown()
|
|
192
163
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
if(!resp.ok) {
|
|
199
|
-
return resp.json().then(data => {
|
|
200
|
-
throw new Error(data.errors ? data.errors.join(", ") : "Save failed")
|
|
201
|
-
})
|
|
202
|
-
}
|
|
203
|
-
// Optionally: visual feedback
|
|
204
|
-
}).catch(err => {
|
|
205
|
-
// Remove pill if failed
|
|
206
|
-
this.removePillById(id)
|
|
207
|
-
alert(err.message || "Failed to add. Please try again.")
|
|
164
|
+
} else {
|
|
165
|
+
// Uppercase word - search GENUS ONLY (no species fallback)
|
|
166
|
+
// This prevents words like "Still" from matching species names with substring matches
|
|
167
|
+
const genusResponse = await fetch(`${this.genusUrlValue}?q=${encodeURIComponent(query)}`, {
|
|
168
|
+
headers: { "Accept": "application/json" }
|
|
208
169
|
})
|
|
209
|
-
}
|
|
210
170
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
if(kind=="genera") {
|
|
220
|
-
route = `/genus_mushrooms/destroy_by_relation.json?mushroom_id=${mushroomId}&genus_id=${id}`
|
|
221
|
-
} else if (kind=="species") {
|
|
222
|
-
route = `/mushroom_species/destroy_by_relation.json?mushroom_id=${mushroomId}&species_id=${id}`
|
|
223
|
-
} else if (kind=="trees") {
|
|
224
|
-
route = `/mushroom_trees/destroy_by_relation.json?mushroom_id=${mushroomId}&tree_id=${id}`
|
|
225
|
-
} else if (kind=="plants") {
|
|
226
|
-
route = `/mushroom_plants/destroy_by_relation.json?mushroom_id=${mushroomId}&plant_id=${id}`
|
|
171
|
+
if (genusResponse.ok) {
|
|
172
|
+
const genusData = await genusResponse.json()
|
|
173
|
+
|
|
174
|
+
if (genusData.length > 0) {
|
|
175
|
+
this.renderDropdown(genusData)
|
|
176
|
+
} else {
|
|
177
|
+
this.hideDropdown()
|
|
178
|
+
}
|
|
227
179
|
} else {
|
|
228
|
-
|
|
229
|
-
return
|
|
180
|
+
this.hideDropdown()
|
|
230
181
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
if(!resp.ok) {
|
|
236
|
-
// Try to get the response text to see what's actually being returned
|
|
237
|
-
return resp.text().then(text => {
|
|
238
|
-
console.error('Delete failed. Status:', resp.status, 'Response:', text)
|
|
239
|
-
try {
|
|
240
|
-
const data = JSON.parse(text)
|
|
241
|
-
throw new Error(data.message || `Delete failed (${resp.status})`)
|
|
242
|
-
} catch(e) {
|
|
243
|
-
throw new Error(`Delete failed (${resp.status}): ${text.substring(0, 100)}`)
|
|
244
|
-
}
|
|
245
|
-
})
|
|
246
|
-
}
|
|
247
|
-
return resp.json()
|
|
248
|
-
}).then(_data => {
|
|
249
|
-
// Success
|
|
250
|
-
console.log('Delete successful')
|
|
251
|
-
if(onSuccess) onSuccess()
|
|
252
|
-
}).catch(err => {
|
|
253
|
-
console.error('Delete error:', err)
|
|
254
|
-
alert(err.message || "Failed to remove. Please try again.")
|
|
255
|
-
if(onError) onError()
|
|
256
|
-
})
|
|
182
|
+
}
|
|
183
|
+
} catch (error) {
|
|
184
|
+
console.error("Autocomplete error:", error)
|
|
185
|
+
this.hideDropdown()
|
|
257
186
|
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
renderDropdown(items) {
|
|
190
|
+
this.dropdownTarget.innerHTML = items
|
|
191
|
+
.map(item => `
|
|
192
|
+
<li class="px-4 py-3 hover:bg-blue-500 hover:text-white cursor-pointer border-b border-gray-200 last:border-b-0"
|
|
193
|
+
data-action="click->inline-autocomplete#selectItem"
|
|
194
|
+
data-name="${item.name}">
|
|
195
|
+
<strong class="text-base">${item.name}</strong>
|
|
196
|
+
</li>
|
|
197
|
+
`).join("")
|
|
198
|
+
|
|
199
|
+
this.dropdownTarget.classList.remove("hidden")
|
|
200
|
+
}
|
|
258
201
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
202
|
+
selectItem(event) {
|
|
203
|
+
event.preventDefault()
|
|
204
|
+
event.stopPropagation()
|
|
205
|
+
|
|
206
|
+
const selectedName = event.currentTarget.dataset.name
|
|
207
|
+
const textarea = this.textareaTarget
|
|
208
|
+
const text = textarea.value
|
|
209
|
+
|
|
210
|
+
// Check if we're replacing a species epithet (lowercase after genus)
|
|
211
|
+
// If so, we need to replace BOTH the genus and species words to avoid duplication
|
|
212
|
+
const isLowercaseWord = /^[a-z]/.test(this.currentWord)
|
|
213
|
+
const hasPrevGenus = this.hasPrecedingCapitalizedWord(text, this.wordStart)
|
|
214
|
+
|
|
215
|
+
let before, after, replaceStart
|
|
216
|
+
if (isLowercaseWord && hasPrevGenus) {
|
|
217
|
+
// Find start of previous genus word
|
|
218
|
+
const prevGenus = this.getPreviousWord(text, this.wordStart)
|
|
219
|
+
const genusStart = this.wordStart - prevGenus.length - 1 // -1 for space
|
|
220
|
+
|
|
221
|
+
replaceStart = genusStart
|
|
222
|
+
before = text.substring(0, genusStart)
|
|
223
|
+
after = text.substring(this.cursorPosition)
|
|
224
|
+
} else {
|
|
225
|
+
// Normal replacement - just replace current word
|
|
226
|
+
replaceStart = this.wordStart
|
|
227
|
+
before = text.substring(0, this.wordStart)
|
|
228
|
+
after = text.substring(this.cursorPosition)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Insert plain text without HTML tags
|
|
232
|
+
// Applications can apply italics via CSS if needed (e.g., textarea { font-style: italic; })
|
|
233
|
+
const isBinomial = selectedName.includes(' ')
|
|
234
|
+
|
|
235
|
+
let formattedName, cursorOffset
|
|
236
|
+
if (isBinomial) {
|
|
237
|
+
// Complete binomial - plain text, no HTML tags
|
|
238
|
+
formattedName = selectedName
|
|
239
|
+
cursorOffset = selectedName.length
|
|
240
|
+
} else {
|
|
241
|
+
// Genus only - add trailing space for convenience
|
|
242
|
+
formattedName = selectedName + ' '
|
|
243
|
+
cursorOffset = selectedName.length + 1
|
|
264
244
|
}
|
|
265
245
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
246
|
+
textarea.value = before + formattedName + after
|
|
247
|
+
|
|
248
|
+
// Hide dropdown immediately
|
|
249
|
+
this.hideDropdown()
|
|
250
|
+
|
|
251
|
+
// Position cursor after the insertion
|
|
252
|
+
const newPosition = before.length + cursorOffset
|
|
253
|
+
textarea.setSelectionRange(newPosition, newPosition)
|
|
254
|
+
textarea.focus()
|
|
255
|
+
|
|
256
|
+
// Set flag to ignore the input event we're about to trigger
|
|
257
|
+
this.ignoreNextInput = true
|
|
258
|
+
|
|
259
|
+
// Trigger input event for character count update
|
|
260
|
+
textarea.dispatchEvent(new Event('input', { bubbles: true }))
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
hideDropdown() {
|
|
264
|
+
this.dropdownTarget.classList.add("hidden")
|
|
265
|
+
this.dropdownTarget.innerHTML = ""
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
onKeydown(event) {
|
|
269
|
+
// Handle keyboard navigation in dropdown
|
|
270
|
+
if (!this.dropdownTarget.classList.contains("hidden")) {
|
|
271
|
+
if (event.key === "Escape") {
|
|
272
|
+
this.hideDropdown()
|
|
273
|
+
event.preventDefault()
|
|
274
|
+
} else if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
275
|
+
// TODO: Add arrow key navigation through dropdown items
|
|
276
|
+
event.preventDefault()
|
|
277
|
+
}
|
|
269
278
|
}
|
|
279
|
+
}
|
|
270
280
|
}
|
|
@@ -4,36 +4,36 @@ Mycowriter has been installed successfully!
|
|
|
4
4
|
|
|
5
5
|
Next steps:
|
|
6
6
|
|
|
7
|
-
1.
|
|
8
|
-
rails db:migrate
|
|
7
|
+
1. Restart your Rails server to load the new Stimulus controller
|
|
9
8
|
|
|
10
9
|
2. Ensure you have the mb_lists table in your database with taxonomic data.
|
|
11
10
|
The gem expects a table with at least these columns:
|
|
12
11
|
- id
|
|
13
12
|
- taxon_name
|
|
14
13
|
- rank_name (optional, for filtering by taxonomic rank)
|
|
14
|
+
- name_status (optional, recommended: filters to 'Legitimate' names only)
|
|
15
15
|
|
|
16
|
-
3.
|
|
16
|
+
3. Use in your text fields (articles, descriptions, notes):
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
<div data-controller="inline-autocomplete"
|
|
19
|
+
data-inline-autocomplete-genus-url-value="<%= mycowriter.genera_autocomplete_path %>"
|
|
20
|
+
data-inline-autocomplete-species-url-value="<%= mycowriter.species_autocomplete_path %>"
|
|
21
|
+
data-inline-autocomplete-min-value="4">
|
|
20
22
|
|
|
21
|
-
|
|
23
|
+
<%= f.text_area :body,
|
|
24
|
+
data: {
|
|
25
|
+
inline_autocomplete_target: "textarea",
|
|
26
|
+
action: "input->inline-autocomplete#onInput keydown->inline-autocomplete#onKeydown"
|
|
27
|
+
},
|
|
28
|
+
class: "form-textarea" %>
|
|
22
29
|
|
|
23
|
-
|
|
24
|
-
data-mycowriter--autocomplete-url-value="/mycowriter/autocomplete/genera"
|
|
25
|
-
data-mycowriter--autocomplete-kind-value="genera"
|
|
26
|
-
data-mycowriter--autocomplete-mushroom-id-value="<%= @mushroom.id %>">
|
|
27
|
-
|
|
28
|
-
<input type="text"
|
|
29
|
-
data-mycowriter--autocomplete-target="input"
|
|
30
|
-
placeholder="Type genus name..." />
|
|
31
|
-
|
|
32
|
-
<ul data-mycowriter--autocomplete-target="dropdown" class="hidden"></ul>
|
|
33
|
-
<div data-mycowriter--autocomplete-target="list"></div>
|
|
30
|
+
<div data-inline-autocomplete-target="dropdown" class="hidden"></div>
|
|
34
31
|
</div>
|
|
35
32
|
|
|
36
|
-
|
|
33
|
+
When typing with a capital letter + 3 chars, genus suggestions appear.
|
|
34
|
+
Select a genus, then continue typing species for inline completion.
|
|
35
|
+
|
|
36
|
+
4. Configure Mycowriter in config/initializers/mycowriter.rb
|
|
37
37
|
|
|
38
38
|
For more information, visit https://mycowriter.com
|
|
39
39
|
|
|
@@ -11,6 +11,10 @@ module Mycowriter
|
|
|
11
11
|
template "mycowriter.rb", "config/initializers/mycowriter.rb"
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
+
def copy_javascript
|
|
15
|
+
template "autocomplete_controller.js", "app/javascript/controllers/inline_autocomplete_controller.js"
|
|
16
|
+
end
|
|
17
|
+
|
|
14
18
|
def add_route
|
|
15
19
|
route 'mount Mycowriter::Engine => "/mycowriter"'
|
|
16
20
|
end
|
data/lib/mycowriter/version.rb
CHANGED