senren-ui 0.1.0
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 +7 -0
- data/CHANGELOG.md +33 -0
- data/CONTRIBUTING.md +63 -0
- data/LICENSE +21 -0
- data/README.md +135 -0
- data/Rakefile +22 -0
- data/docs/visual_style.md +51 -0
- data/lib/generators/senren/component/component_generator.rb +62 -0
- data/lib/generators/senren/component/templates/component.html.erb.tt +3 -0
- data/lib/generators/senren/component/templates/component.rb.tt +13 -0
- data/lib/generators/senren/component/templates/component_test.rb.tt +16 -0
- data/lib/generators/senren/component/templates/controller.js.tt +23 -0
- data/lib/generators/senren/component/templates/system_test.rb.tt +7 -0
- data/lib/generators/senren/install/install_generator.rb +67 -0
- data/lib/generators/senren/install/templates/base_component.rb.tt +45 -0
- data/lib/generators/senren/install/templates/conventions.md.tt +66 -0
- data/lib/generators/senren/install/templates/installed_components.yml.tt +4 -0
- data/lib/generators/senren/install/templates/senren.css.tt +164 -0
- data/lib/senren/rails/component_copier.rb +111 -0
- data/lib/senren/rails/doctor.rb +86 -0
- data/lib/senren/rails/engine.rb +16 -0
- data/lib/senren/rails/host_paths.rb +36 -0
- data/lib/senren/rails/installer.rb +83 -0
- data/lib/senren/rails/llms_writer.rb +149 -0
- data/lib/senren/rails/registry.rb +161 -0
- data/lib/senren/rails/skill_writer.rb +166 -0
- data/lib/senren/rails/version.rb +7 -0
- data/lib/senren/rails.rb +39 -0
- data/lib/tasks/senren.rake +74 -0
- data/registry/components.yml +1053 -0
- data/registry/groups.yml +25 -0
- data/registry/recipes.yml +79 -0
- data/templates/components/accordion/accordion_component.html.erb +16 -0
- data/templates/components/accordion/accordion_component.rb +31 -0
- data/templates/components/activity_feed/activity_feed_component.html.erb +22 -0
- data/templates/components/activity_feed/activity_feed_component.rb +19 -0
- data/templates/components/alert/alert_component.html.erb +9 -0
- data/templates/components/alert/alert_component.rb +18 -0
- data/templates/components/alert_dialog/alert_dialog_component.html.erb +34 -0
- data/templates/components/alert_dialog/alert_dialog_component.rb +21 -0
- data/templates/components/api_key_field/api_key_field_component.html.erb +13 -0
- data/templates/components/api_key_field/api_key_field_component.rb +20 -0
- data/templates/components/app_shell/app_shell_component.html.erb +28 -0
- data/templates/components/app_shell/app_shell_component.rb +24 -0
- data/templates/components/aspect_ratio/aspect_ratio_component.html.erb +3 -0
- data/templates/components/aspect_ratio/aspect_ratio_component.rb +14 -0
- data/templates/components/avatar/avatar_component.html.erb +27 -0
- data/templates/components/avatar/avatar_component.rb +30 -0
- data/templates/components/badge/badge_component.html.erb +1 -0
- data/templates/components/badge/badge_component.rb +16 -0
- data/templates/components/billing_plan_card/billing_plan_card_component.html.erb +28 -0
- data/templates/components/billing_plan_card/billing_plan_card_component.rb +27 -0
- data/templates/components/breadcrumb/breadcrumb_component.html.erb +23 -0
- data/templates/components/breadcrumb/breadcrumb_component.rb +30 -0
- data/templates/components/bulk_action_bar/bulk_action_bar_component.html.erb +12 -0
- data/templates/components/bulk_action_bar/bulk_action_bar_component.rb +24 -0
- data/templates/components/button/button_component.html.erb +6 -0
- data/templates/components/button/button_component.rb +29 -0
- data/templates/components/calendar/calendar_component.html.erb +21 -0
- data/templates/components/calendar/calendar_component.rb +30 -0
- data/templates/components/card/card_component.html.erb +13 -0
- data/templates/components/card/card_component.rb +17 -0
- data/templates/components/carousel/carousel_component.html.erb +68 -0
- data/templates/components/carousel/carousel_component.rb +34 -0
- data/templates/components/checkbox/checkbox_component.html.erb +8 -0
- data/templates/components/checkbox/checkbox_component.rb +19 -0
- data/templates/components/checkbox_group/checkbox_group_component.html.erb +10 -0
- data/templates/components/checkbox_group/checkbox_group_component.rb +30 -0
- data/templates/components/clipboard/clipboard_component.html.erb +7 -0
- data/templates/components/clipboard/clipboard_component.rb +17 -0
- data/templates/components/codeblock/codeblock_component.html.erb +11 -0
- data/templates/components/codeblock/codeblock_component.rb +31 -0
- data/templates/components/collapsible/collapsible_component.html.erb +9 -0
- data/templates/components/collapsible/collapsible_component.rb +19 -0
- data/templates/components/combobox/combobox_component.html.erb +19 -0
- data/templates/components/combobox/combobox_component.rb +38 -0
- data/templates/components/command/command_component.html.erb +22 -0
- data/templates/components/command/command_component.rb +38 -0
- data/templates/components/context_menu/context_menu_component.html.erb +11 -0
- data/templates/components/context_menu/context_menu_component.rb +11 -0
- data/templates/components/data_table/data_table_component.html.erb +50 -0
- data/templates/components/data_table/data_table_component.rb +42 -0
- data/templates/components/date_picker/date_picker_component.html.erb +5 -0
- data/templates/components/date_picker/date_picker_component.rb +21 -0
- data/templates/components/dialog/dialog_component.html.erb +38 -0
- data/templates/components/dialog/dialog_component.rb +22 -0
- data/templates/components/dropdown_menu/dropdown_menu_component.html.erb +12 -0
- data/templates/components/dropdown_menu/dropdown_menu_component.rb +36 -0
- data/templates/components/empty_state/empty_state_component.html.erb +18 -0
- data/templates/components/empty_state/empty_state_component.rb +22 -0
- data/templates/components/filter_bar/filter_bar_component.html.erb +5 -0
- data/templates/components/filter_bar/filter_bar_component.rb +15 -0
- data/templates/components/form/form_component.html.erb +3 -0
- data/templates/components/form/form_component.rb +18 -0
- data/templates/components/hover_card/hover_card_component.html.erb +10 -0
- data/templates/components/hover_card/hover_card_component.rb +11 -0
- data/templates/components/input/input_component.html.erb +1 -0
- data/templates/components/input/input_component.rb +28 -0
- data/templates/components/invite_member_dialog/invite_member_dialog_component.html.erb +35 -0
- data/templates/components/invite_member_dialog/invite_member_dialog_component.rb +26 -0
- data/templates/components/label/label_component.html.erb +4 -0
- data/templates/components/label/label_component.rb +19 -0
- data/templates/components/link/link_component.html.erb +1 -0
- data/templates/components/link/link_component.rb +25 -0
- data/templates/components/masked_input/masked_input_component.html.erb +1 -0
- data/templates/components/masked_input/masked_input_component.rb +18 -0
- data/templates/components/native_select/native_select_component.html.erb +14 -0
- data/templates/components/native_select/native_select_component.rb +52 -0
- data/templates/components/page_header/page_header_component.html.erb +20 -0
- data/templates/components/page_header/page_header_component.rb +19 -0
- data/templates/components/pagination/pagination_component.html.erb +11 -0
- data/templates/components/pagination/pagination_component.rb +24 -0
- data/templates/components/popover/popover_component.html.erb +9 -0
- data/templates/components/popover/popover_component.rb +11 -0
- data/templates/components/progress/progress_component.html.erb +11 -0
- data/templates/components/progress/progress_component.rb +26 -0
- data/templates/components/radio_button/radio_button_component.html.erb +8 -0
- data/templates/components/radio_button/radio_button_component.rb +19 -0
- data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.html.erb +32 -0
- data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.rb +30 -0
- data/templates/components/search_input/search_input_component.html.erb +14 -0
- data/templates/components/search_input/search_input_component.rb +18 -0
- data/templates/components/select/select_component.html.erb +1 -0
- data/templates/components/select/select_component.rb +19 -0
- data/templates/components/separator/separator_component.html.erb +1 -0
- data/templates/components/separator/separator_component.rb +12 -0
- data/templates/components/settings_section/settings_section_component.html.erb +20 -0
- data/templates/components/settings_section/settings_section_component.rb +18 -0
- data/templates/components/sheet/sheet_component.html.erb +37 -0
- data/templates/components/sheet/sheet_component.rb +27 -0
- data/templates/components/shortcut_key/shortcut_key_component.html.erb +6 -0
- data/templates/components/shortcut_key/shortcut_key_component.rb +15 -0
- data/templates/components/sidebar/sidebar_component.html.erb +14 -0
- data/templates/components/sidebar/sidebar_component.rb +37 -0
- data/templates/components/skeleton/skeleton_component.html.erb +1 -0
- data/templates/components/skeleton/skeleton_component.rb +13 -0
- data/templates/components/stat_card/stat_card_component.html.erb +20 -0
- data/templates/components/stat_card/stat_card_component.rb +31 -0
- data/templates/components/switch/switch_component.html.erb +11 -0
- data/templates/components/switch/switch_component.rb +19 -0
- data/templates/components/table/table_component.html.erb +26 -0
- data/templates/components/table/table_component.rb +35 -0
- data/templates/components/tabs/tabs_component.html.erb +18 -0
- data/templates/components/tabs/tabs_component.rb +35 -0
- data/templates/components/team_member_list/team_member_list_component.html.erb +22 -0
- data/templates/components/team_member_list/team_member_list_component.rb +26 -0
- data/templates/components/textarea/textarea_component.html.erb +1 -0
- data/templates/components/textarea/textarea_component.rb +23 -0
- data/templates/components/theme_toggle/theme_toggle_component.html.erb +4 -0
- data/templates/components/theme_toggle/theme_toggle_component.rb +15 -0
- data/templates/components/tooltip/tooltip_component.html.erb +9 -0
- data/templates/components/tooltip/tooltip_component.rb +16 -0
- data/templates/components/top_nav/top_nav_component.html.erb +21 -0
- data/templates/components/top_nav/top_nav_component.rb +44 -0
- data/templates/components/typography/typography_component.html.erb +1 -0
- data/templates/components/typography/typography_component.rb +24 -0
- data/templates/controllers/accordion_controller.js +27 -0
- data/templates/controllers/alert_dialog_controller.js +38 -0
- data/templates/controllers/api_key_field_controller.js +36 -0
- data/templates/controllers/calendar_controller.js +16 -0
- data/templates/controllers/carousel_controller.js +50 -0
- data/templates/controllers/clipboard_controller.js +17 -0
- data/templates/controllers/collapsible_controller.js +13 -0
- data/templates/controllers/combobox_controller.js +64 -0
- data/templates/controllers/command_controller.js +80 -0
- data/templates/controllers/context_menu_controller.js +36 -0
- data/templates/controllers/data_table_controller.js +34 -0
- data/templates/controllers/date_picker_controller.js +17 -0
- data/templates/controllers/dialog_controller.js +50 -0
- data/templates/controllers/dropdown_menu_controller.js +92 -0
- data/templates/controllers/hover_card_controller.js +17 -0
- data/templates/controllers/invite_member_dialog_controller.js +28 -0
- data/templates/controllers/masked_input_controller.js +30 -0
- data/templates/controllers/popover_controller.js +42 -0
- data/templates/controllers/rich_text_editor_lite_controller.js +443 -0
- data/templates/controllers/select_controller.js +10 -0
- data/templates/controllers/sheet_controller.js +34 -0
- data/templates/controllers/sidebar_controller.js +10 -0
- data/templates/controllers/tabs_controller.js +41 -0
- data/templates/controllers/theme_toggle_controller.js +24 -0
- data/templates/controllers/tooltip_controller.js +10 -0
- metadata +257 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// senren--dialog
|
|
4
|
+
// Local UI: open/close, focus trap, Escape to close, body scroll lock.
|
|
5
|
+
export default class extends Controller {
|
|
6
|
+
static targets = ["overlay", "panel", "trigger"]
|
|
7
|
+
static values = { open: Boolean }
|
|
8
|
+
|
|
9
|
+
connect() {
|
|
10
|
+
this._onKey = this._onKey.bind(this)
|
|
11
|
+
if (this.openValue) this._show()
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
disconnect() {
|
|
15
|
+
document.removeEventListener("keydown", this._onKey)
|
|
16
|
+
document.body.style.overflow = ""
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
open(event) {
|
|
20
|
+
event?.preventDefault()
|
|
21
|
+
this._show()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
close(event) {
|
|
25
|
+
event?.preventDefault()
|
|
26
|
+
this._hide()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
_show() {
|
|
30
|
+
this.openValue = true
|
|
31
|
+
if (this.hasOverlayTarget) this.overlayTarget.hidden = false
|
|
32
|
+
if (this.hasPanelTarget) this.panelTarget.hidden = false
|
|
33
|
+
document.addEventListener("keydown", this._onKey)
|
|
34
|
+
document.body.style.overflow = "hidden"
|
|
35
|
+
queueMicrotask(() => this.panelTarget?.focus())
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
_hide() {
|
|
39
|
+
this.openValue = false
|
|
40
|
+
if (this.hasOverlayTarget) this.overlayTarget.hidden = true
|
|
41
|
+
if (this.hasPanelTarget) this.panelTarget.hidden = true
|
|
42
|
+
document.removeEventListener("keydown", this._onKey)
|
|
43
|
+
document.body.style.overflow = ""
|
|
44
|
+
this.triggerTarget?.focus?.()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
_onKey(event) {
|
|
48
|
+
if (event.key === "Escape") this._hide()
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// senren--dropdown-menu
|
|
4
|
+
// Local UI: toggle visibility, close on outside click and Escape, basic arrow nav.
|
|
5
|
+
export default class extends Controller {
|
|
6
|
+
static targets = ["trigger", "menu"]
|
|
7
|
+
|
|
8
|
+
connect() {
|
|
9
|
+
this._onDocClick = this._onDocClick.bind(this)
|
|
10
|
+
this._onKey = this._onKey.bind(this)
|
|
11
|
+
this._setOpenState(false)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
disconnect() {
|
|
15
|
+
document.removeEventListener("click", this._onDocClick)
|
|
16
|
+
document.removeEventListener("keydown", this._onKey)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
toggle(event) {
|
|
20
|
+
event?.preventDefault()
|
|
21
|
+
this.menuTarget.hidden ? this._show() : this.close()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
close() {
|
|
25
|
+
this._setOpenState(false)
|
|
26
|
+
document.removeEventListener("click", this._onDocClick)
|
|
27
|
+
document.removeEventListener("keydown", this._onKey)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
onTriggerKey(event) {
|
|
31
|
+
if (["Enter", " ", "ArrowDown"].includes(event.key)) {
|
|
32
|
+
event.preventDefault()
|
|
33
|
+
this._show()
|
|
34
|
+
this._focusItem(0)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
onItemKey(event) {
|
|
39
|
+
const items = this._items()
|
|
40
|
+
const idx = items.indexOf(document.activeElement)
|
|
41
|
+
if (event.key === "ArrowDown") { event.preventDefault(); this._focusItem((idx + 1) % items.length) }
|
|
42
|
+
if (event.key === "ArrowUp") { event.preventDefault(); this._focusItem((idx - 1 + items.length) % items.length) }
|
|
43
|
+
if (event.key === "Escape") { this.close(); this.triggerTarget?.focus?.() }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
_show() {
|
|
47
|
+
this._setOpenState(true)
|
|
48
|
+
document.addEventListener("click", this._onDocClick)
|
|
49
|
+
document.addEventListener("keydown", this._onKey)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
_setOpenState(open) {
|
|
53
|
+
const state = open ? "open" : "closed"
|
|
54
|
+
this.menuTarget.hidden = !open
|
|
55
|
+
this.element.dataset.state = state
|
|
56
|
+
this.triggerTarget.dataset.state = state
|
|
57
|
+
this.triggerTarget.setAttribute("aria-expanded", open ? "true" : "false")
|
|
58
|
+
|
|
59
|
+
const triggerControl = this._triggerControl()
|
|
60
|
+
if (triggerControl && triggerControl !== this.triggerTarget) {
|
|
61
|
+
triggerControl.dataset.state = state
|
|
62
|
+
triggerControl.setAttribute("aria-expanded", open ? "true" : "false")
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
this.triggerTarget.querySelectorAll("[data-senren-chevron]").forEach((chevron) => {
|
|
66
|
+
chevron.dataset.state = state
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
_triggerControl() {
|
|
71
|
+
return this.triggerTarget.matches("button, a, [role='button']")
|
|
72
|
+
? this.triggerTarget
|
|
73
|
+
: this.triggerTarget.querySelector("button, a, [role='button']")
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
_items() {
|
|
77
|
+
return Array.from(this.menuTarget.querySelectorAll('[role="menuitem"]'))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
_focusItem(i) {
|
|
81
|
+
const items = this._items()
|
|
82
|
+
items[i]?.focus()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
_onDocClick(event) {
|
|
86
|
+
if (!this.element.contains(event.target)) this.close()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
_onKey(event) {
|
|
90
|
+
if (event.key === "Escape") this.close()
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// senren--hover-card
|
|
4
|
+
// Show/hide a small content panel on hover or focus, with a 100ms delay.
|
|
5
|
+
export default class extends Controller {
|
|
6
|
+
static targets = ["panel"]
|
|
7
|
+
|
|
8
|
+
show() {
|
|
9
|
+
clearTimeout(this._hideTimer)
|
|
10
|
+
this._showTimer = setTimeout(() => { if (this.hasPanelTarget) this.panelTarget.hidden = false }, 80)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
hide() {
|
|
14
|
+
clearTimeout(this._showTimer)
|
|
15
|
+
this._hideTimer = setTimeout(() => { if (this.hasPanelTarget) this.panelTarget.hidden = true }, 120)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// senren--invite-member-dialog
|
|
4
|
+
export default class extends Controller {
|
|
5
|
+
static targets = ["trigger", "overlay", "panel"]
|
|
6
|
+
|
|
7
|
+
connect() {
|
|
8
|
+
this.closeOnEscape = this.closeOnEscape.bind(this)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
open() {
|
|
12
|
+
this.overlayTarget.hidden = false
|
|
13
|
+
this.panelTarget.hidden = false
|
|
14
|
+
document.addEventListener("keydown", this.closeOnEscape)
|
|
15
|
+
queueMicrotask(() => this.panelTarget.focus())
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
close() {
|
|
19
|
+
this.overlayTarget.hidden = true
|
|
20
|
+
this.panelTarget.hidden = true
|
|
21
|
+
document.removeEventListener("keydown", this.closeOnEscape)
|
|
22
|
+
if (this.hasTriggerTarget) this.triggerTarget.focus()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
closeOnEscape(event) {
|
|
26
|
+
if (event.key === "Escape") this.close()
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// senren--masked-input
|
|
4
|
+
// Apply a simple character mask to an input. Mask DSL:
|
|
5
|
+
// # -> any digit
|
|
6
|
+
// A -> any letter
|
|
7
|
+
// * -> any character
|
|
8
|
+
// Other characters are literal.
|
|
9
|
+
export default class extends Controller {
|
|
10
|
+
static values = { mask: String }
|
|
11
|
+
|
|
12
|
+
connect() {
|
|
13
|
+
this.element.addEventListener("input", this._onInput.bind(this))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
_onInput(event) {
|
|
17
|
+
if (!this.maskValue) return
|
|
18
|
+
const raw = event.target.value.replace(/[^A-Za-z0-9]/g, "")
|
|
19
|
+
let out = ""
|
|
20
|
+
let i = 0
|
|
21
|
+
for (const m of this.maskValue) {
|
|
22
|
+
if (i >= raw.length) break
|
|
23
|
+
if (m === "#" && /[0-9]/.test(raw[i])) { out += raw[i++] }
|
|
24
|
+
else if (m === "A" && /[A-Za-z]/.test(raw[i])) { out += raw[i++] }
|
|
25
|
+
else if (m === "*") { out += raw[i++] }
|
|
26
|
+
else { out += m }
|
|
27
|
+
}
|
|
28
|
+
event.target.value = out
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// senren--popover
|
|
4
|
+
// Toggles a small panel; closes on outside click and Escape.
|
|
5
|
+
export default class extends Controller {
|
|
6
|
+
static targets = ["trigger", "panel"]
|
|
7
|
+
|
|
8
|
+
connect() {
|
|
9
|
+
this._onDocClick = this._onDocClick.bind(this)
|
|
10
|
+
this._onKey = this._onKey.bind(this)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
disconnect() {
|
|
14
|
+
document.removeEventListener("click", this._onDocClick)
|
|
15
|
+
document.removeEventListener("keydown", this._onKey)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
toggle(event) {
|
|
19
|
+
event?.preventDefault()
|
|
20
|
+
this.panelTarget.hidden ? this._show() : this.close()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
close() {
|
|
24
|
+
this.panelTarget.hidden = true
|
|
25
|
+
document.removeEventListener("click", this._onDocClick)
|
|
26
|
+
document.removeEventListener("keydown", this._onKey)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
_show() {
|
|
30
|
+
this.panelTarget.hidden = false
|
|
31
|
+
document.addEventListener("click", this._onDocClick)
|
|
32
|
+
document.addEventListener("keydown", this._onKey)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
_onDocClick(event) {
|
|
36
|
+
if (!this.element.contains(event.target)) this.close()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
_onKey(event) {
|
|
40
|
+
if (event.key === "Escape") this.close()
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// senren--rich-text-editor-lite
|
|
4
|
+
// Local UI: tiny contenteditable toolbar synced to a hidden textarea.
|
|
5
|
+
export default class extends Controller {
|
|
6
|
+
static targets = ["editor", "input", "button"]
|
|
7
|
+
static values = { debug: Boolean }
|
|
8
|
+
|
|
9
|
+
connect() {
|
|
10
|
+
this.savedRange = null
|
|
11
|
+
if (document.queryCommandSupported?.("defaultParagraphSeparator")) {
|
|
12
|
+
document.execCommand("defaultParagraphSeparator", false, "p")
|
|
13
|
+
}
|
|
14
|
+
this.enableToolbar()
|
|
15
|
+
this.sync()
|
|
16
|
+
this.rememberSelection()
|
|
17
|
+
this.updateToolbar()
|
|
18
|
+
this.debug("connect", this.snapshot())
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
keepSelection(event) {
|
|
22
|
+
event.preventDefault()
|
|
23
|
+
this.rememberSelection()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
openLink(event) {
|
|
27
|
+
const link = event.target.closest("a[href]")
|
|
28
|
+
if (!link || !this.editorTarget.contains(link)) return
|
|
29
|
+
|
|
30
|
+
this.debug("openLink", { href: link.href, metaKey: event.metaKey, ctrlKey: event.ctrlKey })
|
|
31
|
+
if (!event.metaKey && !event.ctrlKey) return
|
|
32
|
+
|
|
33
|
+
event.preventDefault()
|
|
34
|
+
window.open(link.href, "_blank", "noopener,noreferrer")
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
format(event) {
|
|
38
|
+
event.preventDefault()
|
|
39
|
+
const command = event.currentTarget.dataset.command
|
|
40
|
+
if (!command) return
|
|
41
|
+
|
|
42
|
+
this.debug("format:start", { command, before: this.snapshot() })
|
|
43
|
+
this.restoreSelection()
|
|
44
|
+
if (command === "createLink") {
|
|
45
|
+
const url = window.prompt("Paste a URL")
|
|
46
|
+
this.debug("createLink:prompt", { url, afterPrompt: this.snapshot() })
|
|
47
|
+
if (!url) return
|
|
48
|
+
this.restoreSelection()
|
|
49
|
+
this.insertLink(url)
|
|
50
|
+
} else if (command === "insertUnorderedList") {
|
|
51
|
+
this.toggleList("ul")
|
|
52
|
+
} else if (command === "insertOrderedList") {
|
|
53
|
+
this.toggleList("ol")
|
|
54
|
+
} else if (command.startsWith("formatBlock:")) {
|
|
55
|
+
this.formatBlocks(command.split(":")[1])
|
|
56
|
+
} else if (command.startsWith("align:")) {
|
|
57
|
+
this.alignBlocks(command.split(":")[1])
|
|
58
|
+
} else {
|
|
59
|
+
this.restoreSelection()
|
|
60
|
+
document.execCommand(command, false, null)
|
|
61
|
+
}
|
|
62
|
+
this.editorTarget.focus()
|
|
63
|
+
this.sync()
|
|
64
|
+
this.updateToolbar()
|
|
65
|
+
this.rememberSelection()
|
|
66
|
+
this.debug("format:done", { command, after: this.snapshot() })
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
sync() {
|
|
70
|
+
this.inputTarget.value = this.editorTarget.innerHTML
|
|
71
|
+
this.debug("sync", { inputValue: this.inputTarget.value })
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
syncSoon() {
|
|
75
|
+
requestAnimationFrame(() => this.sync())
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
updateToolbar() {
|
|
79
|
+
this.buttonTargets.forEach((button) => {
|
|
80
|
+
const command = button.dataset.command
|
|
81
|
+
if (!command || command === "createLink") return
|
|
82
|
+
|
|
83
|
+
if (command.startsWith("formatBlock:")) {
|
|
84
|
+
button.setAttribute("aria-pressed", this.selectionInsideBlock(command.split(":")[1]) ? "true" : "false")
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (command.startsWith("align:")) {
|
|
89
|
+
button.setAttribute("aria-pressed", this.currentAlignment() === command.split(":")[1] ? "true" : "false")
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (command === "insertUnorderedList") {
|
|
94
|
+
button.setAttribute("aria-pressed", this.selectionInsideList("ul") ? "true" : "false")
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (command === "insertOrderedList") {
|
|
99
|
+
button.setAttribute("aria-pressed", this.selectionInsideList("ol") ? "true" : "false")
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
button.setAttribute("aria-pressed", document.queryCommandState(command) ? "true" : "false")
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
enableToolbar() {
|
|
108
|
+
this.buttonTargets.forEach((button) => {
|
|
109
|
+
button.disabled = false
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
rememberSelection() {
|
|
114
|
+
const selection = window.getSelection()
|
|
115
|
+
if (!selection || selection.rangeCount === 0) {
|
|
116
|
+
this.debug("rememberSelection:empty")
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const range = selection.getRangeAt(0)
|
|
121
|
+
if (this.rangeInsideEditor(range)) {
|
|
122
|
+
this.savedRange = range.cloneRange()
|
|
123
|
+
this.debug("rememberSelection:saved", this.describeRange(this.savedRange))
|
|
124
|
+
} else {
|
|
125
|
+
this.debug("rememberSelection:outside", this.describeRange(range))
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
restoreSelection() {
|
|
130
|
+
this.editorTarget.focus()
|
|
131
|
+
if (this.savedRange && this.rangeInsideEditor(this.savedRange)) {
|
|
132
|
+
const selection = window.getSelection()
|
|
133
|
+
selection.removeAllRanges()
|
|
134
|
+
selection.addRange(this.savedRange)
|
|
135
|
+
this.debug("restoreSelection:saved", this.describeRange(this.savedRange))
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
this.debug("restoreSelection:endFallback", this.snapshot())
|
|
140
|
+
this.placeCaretAtEnd()
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
insertLink(rawUrl) {
|
|
144
|
+
const url = this.normalizeUrl(rawUrl)
|
|
145
|
+
this.debug("insertLink:start", { rawUrl, url, before: this.snapshot() })
|
|
146
|
+
if (!url) return
|
|
147
|
+
|
|
148
|
+
const range = this.activeRange()
|
|
149
|
+
if (!range) {
|
|
150
|
+
this.debug("insertLink:noRange")
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const anchor = document.createElement("a")
|
|
155
|
+
anchor.href = url
|
|
156
|
+
anchor.rel = "noopener noreferrer"
|
|
157
|
+
anchor.target = "_blank"
|
|
158
|
+
this.debug("insertLink:range", this.describeRange(range))
|
|
159
|
+
|
|
160
|
+
if (range.collapsed) {
|
|
161
|
+
anchor.textContent = url
|
|
162
|
+
range.insertNode(anchor)
|
|
163
|
+
} else {
|
|
164
|
+
anchor.appendChild(range.extractContents())
|
|
165
|
+
range.insertNode(anchor)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
range.setStartAfter(anchor)
|
|
169
|
+
range.collapse(true)
|
|
170
|
+
const selection = window.getSelection()
|
|
171
|
+
selection.removeAllRanges()
|
|
172
|
+
selection.addRange(range)
|
|
173
|
+
this.debug("insertLink:done", { anchor: anchor.outerHTML, after: this.snapshot() })
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
toggleList(tagName) {
|
|
177
|
+
const range = this.activeRange()
|
|
178
|
+
this.debug("toggleList:start", { tagName, range: this.describeRange(range), before: this.snapshot() })
|
|
179
|
+
if (!range) return
|
|
180
|
+
|
|
181
|
+
const activeList = this.closestElement(range.startContainer, "ul, ol")
|
|
182
|
+
if (activeList && this.editorTarget.contains(activeList)) {
|
|
183
|
+
if (activeList.tagName.toLowerCase() === tagName) {
|
|
184
|
+
this.unwrapList(activeList)
|
|
185
|
+
} else {
|
|
186
|
+
this.convertList(activeList, tagName)
|
|
187
|
+
}
|
|
188
|
+
this.debug("toggleList:existingList", { tagName, after: this.snapshot() })
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const blocks = this.selectedBlocks(range)
|
|
193
|
+
this.debug("toggleList:blocks", { tagName, blocks: blocks.map((block) => block.outerHTML) })
|
|
194
|
+
if (blocks.length === 0) return
|
|
195
|
+
|
|
196
|
+
const list = document.createElement(tagName)
|
|
197
|
+
blocks.forEach((block) => {
|
|
198
|
+
const item = document.createElement("li")
|
|
199
|
+
while (block.firstChild) item.appendChild(block.firstChild)
|
|
200
|
+
if (item.childNodes.length === 0) item.appendChild(document.createElement("br"))
|
|
201
|
+
list.appendChild(item)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
blocks[0].before(list)
|
|
205
|
+
blocks.forEach((block) => block.remove())
|
|
206
|
+
this.selectNodeContents(list)
|
|
207
|
+
this.debug("toggleList:done", { list: list.outerHTML, after: this.snapshot() })
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
formatBlocks(tagName) {
|
|
211
|
+
const normalizedTagName = this.normalizedBlockTag(tagName)
|
|
212
|
+
const range = this.activeRange()
|
|
213
|
+
this.debug("formatBlocks:start", { tagName: normalizedTagName, range: this.describeRange(range) })
|
|
214
|
+
if (!range) return
|
|
215
|
+
|
|
216
|
+
const blocks = this.selectedBlocks(range)
|
|
217
|
+
if (blocks.length === 0) return
|
|
218
|
+
|
|
219
|
+
const replacements = blocks.map((block) => this.replaceBlock(block, normalizedTagName))
|
|
220
|
+
this.selectNodeContents(replacements[replacements.length - 1])
|
|
221
|
+
this.debug("formatBlocks:done", { tagName: normalizedTagName, blocks: replacements.map((block) => block.outerHTML) })
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
alignBlocks(alignment) {
|
|
225
|
+
const normalizedAlignment = this.normalizedAlignment(alignment)
|
|
226
|
+
const range = this.activeRange()
|
|
227
|
+
this.debug("alignBlocks:start", { alignment: normalizedAlignment, range: this.describeRange(range) })
|
|
228
|
+
if (!range) return
|
|
229
|
+
|
|
230
|
+
const blocks = this.selectedBlocks(range)
|
|
231
|
+
blocks.forEach((block) => {
|
|
232
|
+
if (this.blockAlignment(block) === normalizedAlignment || normalizedAlignment === "left") {
|
|
233
|
+
block.removeAttribute("data-align")
|
|
234
|
+
} else {
|
|
235
|
+
block.dataset.align = normalizedAlignment
|
|
236
|
+
}
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
if (blocks.length > 0) this.selectNodeContents(blocks[blocks.length - 1])
|
|
240
|
+
this.debug("alignBlocks:done", { alignment: normalizedAlignment, blocks: blocks.map((block) => block.outerHTML) })
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
unwrapList(list) {
|
|
244
|
+
const paragraphs = Array.from(list.children).map((item) => {
|
|
245
|
+
const paragraph = document.createElement("p")
|
|
246
|
+
while (item.firstChild) paragraph.appendChild(item.firstChild)
|
|
247
|
+
if (paragraph.childNodes.length === 0) paragraph.appendChild(document.createElement("br"))
|
|
248
|
+
return paragraph
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
list.replaceWith(...paragraphs)
|
|
252
|
+
this.selectNodeContents(paragraphs[paragraphs.length - 1])
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
convertList(list, tagName) {
|
|
256
|
+
const replacement = document.createElement(tagName)
|
|
257
|
+
while (list.firstChild) replacement.appendChild(list.firstChild)
|
|
258
|
+
list.replaceWith(replacement)
|
|
259
|
+
this.selectNodeContents(replacement)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
placeCaretAtEnd() {
|
|
263
|
+
const range = document.createRange()
|
|
264
|
+
range.selectNodeContents(this.editorTarget)
|
|
265
|
+
range.collapse(false)
|
|
266
|
+
const selection = window.getSelection()
|
|
267
|
+
selection.removeAllRanges()
|
|
268
|
+
selection.addRange(range)
|
|
269
|
+
this.savedRange = range.cloneRange()
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
rangeInsideEditor(range) {
|
|
273
|
+
try {
|
|
274
|
+
return this.editorTarget.contains(range.commonAncestorContainer)
|
|
275
|
+
} catch (_) {
|
|
276
|
+
return false
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
normalizeUrl(rawUrl) {
|
|
281
|
+
const url = String(rawUrl || "").trim()
|
|
282
|
+
if (url.length === 0) return null
|
|
283
|
+
if (/^(?:[a-z][a-z0-9+.-]*:|\/|#)/i.test(url)) return url
|
|
284
|
+
return `https://${url}`
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
activeRange() {
|
|
288
|
+
const selection = window.getSelection()
|
|
289
|
+
if (selection && selection.rangeCount > 0) {
|
|
290
|
+
const range = selection.getRangeAt(0)
|
|
291
|
+
if (this.rangeInsideEditor(range)) {
|
|
292
|
+
this.debug("activeRange:selection", this.describeRange(range))
|
|
293
|
+
return range
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (this.savedRange && this.rangeInsideEditor(this.savedRange)) {
|
|
298
|
+
this.debug("activeRange:saved", this.describeRange(this.savedRange))
|
|
299
|
+
return this.savedRange
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
this.placeCaretAtEnd()
|
|
303
|
+
const range = window.getSelection().getRangeAt(0)
|
|
304
|
+
this.debug("activeRange:endFallback", this.describeRange(range))
|
|
305
|
+
return range
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
selectedBlocks(range) {
|
|
309
|
+
const selected = Array.from(this.editorTarget.querySelectorAll("p, div, li, blockquote, h1, h2, h3, h4, h5, h6"))
|
|
310
|
+
.filter((block) => {
|
|
311
|
+
try {
|
|
312
|
+
return range.intersectsNode(block)
|
|
313
|
+
} catch (_) {
|
|
314
|
+
return false
|
|
315
|
+
}
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
if (selected.length > 0) return selected
|
|
319
|
+
|
|
320
|
+
const block = this.closestBlock(range.commonAncestorContainer)
|
|
321
|
+
if (block) return [block]
|
|
322
|
+
|
|
323
|
+
const paragraph = document.createElement("p")
|
|
324
|
+
paragraph.appendChild(range.extractContents())
|
|
325
|
+
if (paragraph.childNodes.length === 0) paragraph.appendChild(document.createElement("br"))
|
|
326
|
+
range.insertNode(paragraph)
|
|
327
|
+
return [paragraph]
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
closestBlock(node) {
|
|
331
|
+
return this.closestElement(node, "p, div, li, blockquote, h1, h2, h3, h4, h5, h6")
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
closestElement(node, selector) {
|
|
335
|
+
const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement
|
|
336
|
+
return element?.closest(selector)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
selectionInsideList(tagName) {
|
|
340
|
+
const range = this.selectionRange()
|
|
341
|
+
return !!(range && this.closestElement(range.startContainer, tagName))
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
selectionInsideBlock(tagName) {
|
|
345
|
+
const range = this.selectionRange()
|
|
346
|
+
const block = range && this.closestBlock(range.startContainer)
|
|
347
|
+
return block?.tagName.toLowerCase() === this.normalizedBlockTag(tagName)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
currentAlignment() {
|
|
351
|
+
const range = this.selectionRange()
|
|
352
|
+
const block = range && this.closestBlock(range.startContainer)
|
|
353
|
+
return this.blockAlignment(block)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
blockAlignment(block) {
|
|
357
|
+
return block?.dataset.align || "left"
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
replaceBlock(block, tagName) {
|
|
361
|
+
if (block.tagName.toLowerCase() === tagName) return block
|
|
362
|
+
|
|
363
|
+
const replacement = document.createElement(tagName)
|
|
364
|
+
if (block.dataset.align) replacement.dataset.align = block.dataset.align
|
|
365
|
+
while (block.firstChild) replacement.appendChild(block.firstChild)
|
|
366
|
+
if (replacement.childNodes.length === 0) replacement.appendChild(document.createElement("br"))
|
|
367
|
+
block.replaceWith(replacement)
|
|
368
|
+
return replacement
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
normalizedBlockTag(tagName) {
|
|
372
|
+
return ["p", "h1", "h2", "h3"].includes(tagName) ? tagName : "p"
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
normalizedAlignment(alignment) {
|
|
376
|
+
return ["left", "center", "right", "justify"].includes(alignment) ? alignment : "left"
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
selectNodeContents(node) {
|
|
380
|
+
if (!node) return
|
|
381
|
+
|
|
382
|
+
const range = document.createRange()
|
|
383
|
+
range.selectNodeContents(node)
|
|
384
|
+
const selection = window.getSelection()
|
|
385
|
+
selection.removeAllRanges()
|
|
386
|
+
selection.addRange(range)
|
|
387
|
+
this.savedRange = range.cloneRange()
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
selectionRange() {
|
|
391
|
+
const selection = window.getSelection()
|
|
392
|
+
if (selection && selection.rangeCount > 0) {
|
|
393
|
+
const range = selection.getRangeAt(0)
|
|
394
|
+
if (this.rangeInsideEditor(range)) return range
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (this.savedRange && this.rangeInsideEditor(this.savedRange)) {
|
|
398
|
+
return this.savedRange
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return null
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
snapshot() {
|
|
405
|
+
const range = this.selectionRange()
|
|
406
|
+
return {
|
|
407
|
+
html: this.editorTarget.innerHTML,
|
|
408
|
+
text: this.editorTarget.textContent,
|
|
409
|
+
selection: this.describeRange(range),
|
|
410
|
+
savedSelection: this.describeRange(this.savedRange)
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
describeRange(range) {
|
|
415
|
+
if (!range) return null
|
|
416
|
+
|
|
417
|
+
return {
|
|
418
|
+
collapsed: range.collapsed,
|
|
419
|
+
text: range.toString(),
|
|
420
|
+
start: this.describeNode(range.startContainer),
|
|
421
|
+
startOffset: range.startOffset,
|
|
422
|
+
end: this.describeNode(range.endContainer),
|
|
423
|
+
endOffset: range.endOffset,
|
|
424
|
+
insideEditor: this.rangeInsideEditor(range)
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
describeNode(node) {
|
|
429
|
+
if (!node) return null
|
|
430
|
+
if (node.nodeType === Node.TEXT_NODE) return `#text:${node.textContent}`
|
|
431
|
+
if (node.nodeType !== Node.ELEMENT_NODE) return node.nodeName
|
|
432
|
+
|
|
433
|
+
const id = node.id ? `#${node.id}` : ""
|
|
434
|
+
const classes = node.className ? `.${String(node.className).trim().replace(/\s+/g, ".")}` : ""
|
|
435
|
+
return `${node.tagName.toLowerCase()}${id}${classes}`
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
debug(message, detail = {}) {
|
|
439
|
+
if (!this.debugValue) return
|
|
440
|
+
|
|
441
|
+
console.debug("[senren rich text]", message, detail)
|
|
442
|
+
}
|
|
443
|
+
}
|