mycowriter 0.1.7 → 0.1.10
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 +4 -4
- data/app/controllers/mycowriter/autocomplete_controller.rb +3 -0
- data/app/javascript/mycowriter/autocomplete_controller.js +232 -225
- data/lib/generators/mycowriter/install/README +18 -18
- data/lib/generators/mycowriter/install/install_generator.rb +4 -0
- data/lib/mycowriter/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e1a1c921c961ded2607e45978d58795ee8f92bfef1ac34faa8256ff3b5d47a2d
|
|
4
|
+
data.tar.gz: be73f75a71d970fef1a51f2a9a2d8b1882afb7103cd204f0828e3c3acbc85d3c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6b984d4be064516a2a60285fb81c477a611b18d3a71b97772bf3a159a1629b2dc41030b26eb980eb80cf6059ea92134c2349ca4ab8172a4273743344e67186a3
|
|
7
|
+
data.tar.gz: 044acf422c1eb53015b38cfbb1680df25b156635331310be9f78ea404126bf8e0802c077f06cb0d80bd60c34455d78d40d43224051ef1b311b6b6cb753931a30
|
|
@@ -110,6 +110,9 @@ module Mycowriter
|
|
|
110
110
|
end
|
|
111
111
|
end
|
|
112
112
|
|
|
113
|
+
# Only return legitimate names (exclude illegitimate/invalid names)
|
|
114
|
+
scope = scope.where(name_status: 'Legitimate') if ::MbList.column_names.include?('name_status')
|
|
115
|
+
|
|
113
116
|
scope
|
|
114
117
|
.select(:id, :taxon_name)
|
|
115
118
|
.order(
|
|
@@ -1,270 +1,277 @@
|
|
|
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 (likely a genus name)
|
|
60
|
+
let i = currentWordStart - 1
|
|
68
61
|
|
|
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())
|
|
62
|
+
// Skip whitespace backwards
|
|
63
|
+
while (i >= 0 && /\s/.test(text[i])) {
|
|
64
|
+
i--
|
|
81
65
|
}
|
|
82
66
|
|
|
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")
|
|
67
|
+
if (i < 0) return false
|
|
68
|
+
|
|
69
|
+
// Find start of previous word
|
|
70
|
+
let prevWordEnd = i
|
|
71
|
+
while (i >= 0 && /[a-zA-Z]/.test(text[i])) {
|
|
72
|
+
i--
|
|
98
73
|
}
|
|
99
74
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
75
|
+
const prevWord = text.substring(i + 1, prevWordEnd + 1)
|
|
76
|
+
return prevWord.length > 0 && /^[A-Z]/.test(prevWord)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
getPreviousWord(text, currentWordStart) {
|
|
80
|
+
// Extract the previous word (genus name) for filtering species
|
|
81
|
+
let i = currentWordStart - 1
|
|
82
|
+
|
|
83
|
+
// Skip whitespace backwards
|
|
84
|
+
while (i >= 0 && /\s/.test(text[i])) {
|
|
85
|
+
i--
|
|
103
86
|
}
|
|
104
87
|
|
|
105
|
-
|
|
106
|
-
|
|
88
|
+
if (i < 0) return ""
|
|
89
|
+
|
|
90
|
+
// Find start of previous word
|
|
91
|
+
let prevWordEnd = i
|
|
92
|
+
while (i >= 0 && /[a-zA-Z]/.test(text[i])) {
|
|
93
|
+
i--
|
|
107
94
|
}
|
|
108
|
-
|
|
109
|
-
|
|
95
|
+
|
|
96
|
+
return text.substring(i + 1, prevWordEnd + 1)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
getWordAtCursor(text, position) {
|
|
100
|
+
// Find word boundaries (letters only, no spaces or punctuation)
|
|
101
|
+
let start = position
|
|
102
|
+
let end = position
|
|
103
|
+
|
|
104
|
+
// Move back to start of word
|
|
105
|
+
while (start > 0 && /[a-zA-Z]/.test(text[start - 1])) {
|
|
106
|
+
start--
|
|
110
107
|
}
|
|
111
108
|
|
|
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)
|
|
109
|
+
// Move forward to end of word
|
|
110
|
+
while (end < text.length && /[a-zA-Z]/.test(text[end])) {
|
|
111
|
+
end++
|
|
128
112
|
}
|
|
129
113
|
|
|
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()
|
|
114
|
+
return {
|
|
115
|
+
word: text.substring(start, end),
|
|
116
|
+
start: start,
|
|
117
|
+
end: end
|
|
143
118
|
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async fetchSuggestions(query) {
|
|
122
|
+
try {
|
|
123
|
+
const textarea = this.textareaTarget
|
|
124
|
+
const text = textarea.value
|
|
125
|
+
const isLowercaseAfterGenus = /^[a-z]/.test(query) && this.hasPrecedingCapitalizedWord(text, this.wordStart)
|
|
126
|
+
|
|
127
|
+
// If lowercase word after a capitalized word, search SPECIES first (likely typing species epithet)
|
|
128
|
+
if (isLowercaseAfterGenus) {
|
|
129
|
+
// Extract the previous genus name to filter species by that genus
|
|
130
|
+
const prevGenus = this.getPreviousWord(text, this.wordStart)
|
|
131
|
+
|
|
132
|
+
const speciesResponse = await fetch(
|
|
133
|
+
`${this.speciesUrlValue}?q=${encodeURIComponent(query)}&genus_name=${encodeURIComponent(prevGenus)}`,
|
|
134
|
+
{ headers: { "Accept": "application/json" } }
|
|
135
|
+
)
|
|
144
136
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
const pill = this.listTarget.querySelector(`[data-token-id="${id}"]`)
|
|
148
|
-
const itemName = pill ? pill.textContent.trim().replace('×', '').trim() : 'this item'
|
|
137
|
+
if (speciesResponse.ok) {
|
|
138
|
+
const speciesData = await speciesResponse.json()
|
|
149
139
|
|
|
150
|
-
|
|
151
|
-
|
|
140
|
+
if (speciesData.length > 0) {
|
|
141
|
+
this.renderDropdown(speciesData)
|
|
152
142
|
return
|
|
143
|
+
}
|
|
153
144
|
}
|
|
154
145
|
|
|
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
|
|
146
|
+
// No species matches - try genus as fallback
|
|
147
|
+
const genusResponse = await fetch(`${this.genusUrlValue}?q=${encodeURIComponent(query)}`, {
|
|
148
|
+
headers: { "Accept": "application/json" }
|
|
163
149
|
})
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
updateHiddenIds() {
|
|
167
|
-
if(this.hasHiddenIdsTarget) {
|
|
168
|
-
this.hiddenIdsTarget.value = Array.from(this.selected.keys()).join(",")
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
150
|
|
|
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}})
|
|
151
|
+
if (genusResponse.ok) {
|
|
152
|
+
const genusData = await genusResponse.json()
|
|
153
|
+
if (genusData.length > 0) {
|
|
154
|
+
this.renderDropdown(genusData)
|
|
155
|
+
} else {
|
|
156
|
+
this.hideDropdown()
|
|
157
|
+
}
|
|
190
158
|
} else {
|
|
191
|
-
|
|
159
|
+
this.hideDropdown()
|
|
192
160
|
}
|
|
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.")
|
|
161
|
+
} else {
|
|
162
|
+
// Uppercase word - search GENUS ONLY (no species fallback)
|
|
163
|
+
// This prevents words like "Still" from matching species names with substring matches
|
|
164
|
+
const genusResponse = await fetch(`${this.genusUrlValue}?q=${encodeURIComponent(query)}`, {
|
|
165
|
+
headers: { "Accept": "application/json" }
|
|
208
166
|
})
|
|
209
|
-
}
|
|
210
167
|
|
|
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}`
|
|
168
|
+
if (genusResponse.ok) {
|
|
169
|
+
const genusData = await genusResponse.json()
|
|
170
|
+
|
|
171
|
+
if (genusData.length > 0) {
|
|
172
|
+
this.renderDropdown(genusData)
|
|
173
|
+
} else {
|
|
174
|
+
this.hideDropdown()
|
|
175
|
+
}
|
|
227
176
|
} else {
|
|
228
|
-
|
|
229
|
-
return
|
|
177
|
+
this.hideDropdown()
|
|
230
178
|
}
|
|
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
|
-
})
|
|
179
|
+
}
|
|
180
|
+
} catch (error) {
|
|
181
|
+
console.error("Autocomplete error:", error)
|
|
182
|
+
this.hideDropdown()
|
|
257
183
|
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
renderDropdown(items) {
|
|
187
|
+
this.dropdownTarget.innerHTML = items
|
|
188
|
+
.map(item => `
|
|
189
|
+
<li class="px-4 py-3 hover:bg-blue-500 hover:text-white cursor-pointer border-b border-gray-200 last:border-b-0"
|
|
190
|
+
data-action="click->inline-autocomplete#selectItem"
|
|
191
|
+
data-name="${item.name}">
|
|
192
|
+
<strong class="text-base">${item.name}</strong>
|
|
193
|
+
</li>
|
|
194
|
+
`).join("")
|
|
195
|
+
|
|
196
|
+
this.dropdownTarget.classList.remove("hidden")
|
|
197
|
+
}
|
|
258
198
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
199
|
+
selectItem(event) {
|
|
200
|
+
event.preventDefault()
|
|
201
|
+
event.stopPropagation()
|
|
202
|
+
|
|
203
|
+
const selectedName = event.currentTarget.dataset.name
|
|
204
|
+
const textarea = this.textareaTarget
|
|
205
|
+
const text = textarea.value
|
|
206
|
+
|
|
207
|
+
// Check if we're replacing a species epithet (lowercase after genus)
|
|
208
|
+
// If so, we need to replace BOTH the genus and species words to avoid duplication
|
|
209
|
+
const isLowercaseWord = /^[a-z]/.test(this.currentWord)
|
|
210
|
+
const hasPrevGenus = this.hasPrecedingCapitalizedWord(text, this.wordStart)
|
|
211
|
+
|
|
212
|
+
let before, after, replaceStart
|
|
213
|
+
if (isLowercaseWord && hasPrevGenus) {
|
|
214
|
+
// Find start of previous genus word
|
|
215
|
+
const prevGenus = this.getPreviousWord(text, this.wordStart)
|
|
216
|
+
const genusStart = this.wordStart - prevGenus.length - 1 // -1 for space
|
|
217
|
+
|
|
218
|
+
replaceStart = genusStart
|
|
219
|
+
before = text.substring(0, genusStart)
|
|
220
|
+
after = text.substring(this.cursorPosition)
|
|
221
|
+
} else {
|
|
222
|
+
// Normal replacement - just replace current word
|
|
223
|
+
replaceStart = this.wordStart
|
|
224
|
+
before = text.substring(0, this.wordStart)
|
|
225
|
+
after = text.substring(this.cursorPosition)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Apply <em> tags to complete binomials (contains space like "Ganoderma sessile")
|
|
229
|
+
// This follows scientific naming conventions: binomial names must be italicized
|
|
230
|
+
const isBinomial = selectedName.includes(' ')
|
|
231
|
+
|
|
232
|
+
let formattedName, cursorOffset
|
|
233
|
+
if (isBinomial) {
|
|
234
|
+
// Complete binomial - wrap in <em> tags per nomenclature standards
|
|
235
|
+
formattedName = `<em>${selectedName}</em>`
|
|
236
|
+
cursorOffset = selectedName.length + 9 // position AFTER closing </em> tag
|
|
237
|
+
} else {
|
|
238
|
+
// Genus only - no HTML tags, add trailing space
|
|
239
|
+
formattedName = selectedName + ' '
|
|
240
|
+
cursorOffset = selectedName.length + 1
|
|
264
241
|
}
|
|
265
242
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
243
|
+
textarea.value = before + formattedName + after
|
|
244
|
+
|
|
245
|
+
// Hide dropdown immediately
|
|
246
|
+
this.hideDropdown()
|
|
247
|
+
|
|
248
|
+
// Position cursor after the insertion
|
|
249
|
+
const newPosition = before.length + cursorOffset
|
|
250
|
+
textarea.setSelectionRange(newPosition, newPosition)
|
|
251
|
+
textarea.focus()
|
|
252
|
+
|
|
253
|
+
// Set flag to ignore the input event we're about to trigger
|
|
254
|
+
this.ignoreNextInput = true
|
|
255
|
+
|
|
256
|
+
// Trigger input event for character count update
|
|
257
|
+
textarea.dispatchEvent(new Event('input', { bubbles: true }))
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
hideDropdown() {
|
|
261
|
+
this.dropdownTarget.classList.add("hidden")
|
|
262
|
+
this.dropdownTarget.innerHTML = ""
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
onKeydown(event) {
|
|
266
|
+
// Handle keyboard navigation in dropdown
|
|
267
|
+
if (!this.dropdownTarget.classList.contains("hidden")) {
|
|
268
|
+
if (event.key === "Escape") {
|
|
269
|
+
this.hideDropdown()
|
|
270
|
+
event.preventDefault()
|
|
271
|
+
} else if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
272
|
+
// TODO: Add arrow key navigation through dropdown items
|
|
273
|
+
event.preventDefault()
|
|
274
|
+
}
|
|
269
275
|
}
|
|
276
|
+
}
|
|
270
277
|
}
|
|
@@ -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