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.
Files changed (182) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +33 -0
  3. data/CONTRIBUTING.md +63 -0
  4. data/LICENSE +21 -0
  5. data/README.md +135 -0
  6. data/Rakefile +22 -0
  7. data/docs/visual_style.md +51 -0
  8. data/lib/generators/senren/component/component_generator.rb +62 -0
  9. data/lib/generators/senren/component/templates/component.html.erb.tt +3 -0
  10. data/lib/generators/senren/component/templates/component.rb.tt +13 -0
  11. data/lib/generators/senren/component/templates/component_test.rb.tt +16 -0
  12. data/lib/generators/senren/component/templates/controller.js.tt +23 -0
  13. data/lib/generators/senren/component/templates/system_test.rb.tt +7 -0
  14. data/lib/generators/senren/install/install_generator.rb +67 -0
  15. data/lib/generators/senren/install/templates/base_component.rb.tt +45 -0
  16. data/lib/generators/senren/install/templates/conventions.md.tt +66 -0
  17. data/lib/generators/senren/install/templates/installed_components.yml.tt +4 -0
  18. data/lib/generators/senren/install/templates/senren.css.tt +164 -0
  19. data/lib/senren/rails/component_copier.rb +111 -0
  20. data/lib/senren/rails/doctor.rb +86 -0
  21. data/lib/senren/rails/engine.rb +16 -0
  22. data/lib/senren/rails/host_paths.rb +36 -0
  23. data/lib/senren/rails/installer.rb +83 -0
  24. data/lib/senren/rails/llms_writer.rb +149 -0
  25. data/lib/senren/rails/registry.rb +161 -0
  26. data/lib/senren/rails/skill_writer.rb +166 -0
  27. data/lib/senren/rails/version.rb +7 -0
  28. data/lib/senren/rails.rb +39 -0
  29. data/lib/tasks/senren.rake +74 -0
  30. data/registry/components.yml +1053 -0
  31. data/registry/groups.yml +25 -0
  32. data/registry/recipes.yml +79 -0
  33. data/templates/components/accordion/accordion_component.html.erb +16 -0
  34. data/templates/components/accordion/accordion_component.rb +31 -0
  35. data/templates/components/activity_feed/activity_feed_component.html.erb +22 -0
  36. data/templates/components/activity_feed/activity_feed_component.rb +19 -0
  37. data/templates/components/alert/alert_component.html.erb +9 -0
  38. data/templates/components/alert/alert_component.rb +18 -0
  39. data/templates/components/alert_dialog/alert_dialog_component.html.erb +34 -0
  40. data/templates/components/alert_dialog/alert_dialog_component.rb +21 -0
  41. data/templates/components/api_key_field/api_key_field_component.html.erb +13 -0
  42. data/templates/components/api_key_field/api_key_field_component.rb +20 -0
  43. data/templates/components/app_shell/app_shell_component.html.erb +28 -0
  44. data/templates/components/app_shell/app_shell_component.rb +24 -0
  45. data/templates/components/aspect_ratio/aspect_ratio_component.html.erb +3 -0
  46. data/templates/components/aspect_ratio/aspect_ratio_component.rb +14 -0
  47. data/templates/components/avatar/avatar_component.html.erb +27 -0
  48. data/templates/components/avatar/avatar_component.rb +30 -0
  49. data/templates/components/badge/badge_component.html.erb +1 -0
  50. data/templates/components/badge/badge_component.rb +16 -0
  51. data/templates/components/billing_plan_card/billing_plan_card_component.html.erb +28 -0
  52. data/templates/components/billing_plan_card/billing_plan_card_component.rb +27 -0
  53. data/templates/components/breadcrumb/breadcrumb_component.html.erb +23 -0
  54. data/templates/components/breadcrumb/breadcrumb_component.rb +30 -0
  55. data/templates/components/bulk_action_bar/bulk_action_bar_component.html.erb +12 -0
  56. data/templates/components/bulk_action_bar/bulk_action_bar_component.rb +24 -0
  57. data/templates/components/button/button_component.html.erb +6 -0
  58. data/templates/components/button/button_component.rb +29 -0
  59. data/templates/components/calendar/calendar_component.html.erb +21 -0
  60. data/templates/components/calendar/calendar_component.rb +30 -0
  61. data/templates/components/card/card_component.html.erb +13 -0
  62. data/templates/components/card/card_component.rb +17 -0
  63. data/templates/components/carousel/carousel_component.html.erb +68 -0
  64. data/templates/components/carousel/carousel_component.rb +34 -0
  65. data/templates/components/checkbox/checkbox_component.html.erb +8 -0
  66. data/templates/components/checkbox/checkbox_component.rb +19 -0
  67. data/templates/components/checkbox_group/checkbox_group_component.html.erb +10 -0
  68. data/templates/components/checkbox_group/checkbox_group_component.rb +30 -0
  69. data/templates/components/clipboard/clipboard_component.html.erb +7 -0
  70. data/templates/components/clipboard/clipboard_component.rb +17 -0
  71. data/templates/components/codeblock/codeblock_component.html.erb +11 -0
  72. data/templates/components/codeblock/codeblock_component.rb +31 -0
  73. data/templates/components/collapsible/collapsible_component.html.erb +9 -0
  74. data/templates/components/collapsible/collapsible_component.rb +19 -0
  75. data/templates/components/combobox/combobox_component.html.erb +19 -0
  76. data/templates/components/combobox/combobox_component.rb +38 -0
  77. data/templates/components/command/command_component.html.erb +22 -0
  78. data/templates/components/command/command_component.rb +38 -0
  79. data/templates/components/context_menu/context_menu_component.html.erb +11 -0
  80. data/templates/components/context_menu/context_menu_component.rb +11 -0
  81. data/templates/components/data_table/data_table_component.html.erb +50 -0
  82. data/templates/components/data_table/data_table_component.rb +42 -0
  83. data/templates/components/date_picker/date_picker_component.html.erb +5 -0
  84. data/templates/components/date_picker/date_picker_component.rb +21 -0
  85. data/templates/components/dialog/dialog_component.html.erb +38 -0
  86. data/templates/components/dialog/dialog_component.rb +22 -0
  87. data/templates/components/dropdown_menu/dropdown_menu_component.html.erb +12 -0
  88. data/templates/components/dropdown_menu/dropdown_menu_component.rb +36 -0
  89. data/templates/components/empty_state/empty_state_component.html.erb +18 -0
  90. data/templates/components/empty_state/empty_state_component.rb +22 -0
  91. data/templates/components/filter_bar/filter_bar_component.html.erb +5 -0
  92. data/templates/components/filter_bar/filter_bar_component.rb +15 -0
  93. data/templates/components/form/form_component.html.erb +3 -0
  94. data/templates/components/form/form_component.rb +18 -0
  95. data/templates/components/hover_card/hover_card_component.html.erb +10 -0
  96. data/templates/components/hover_card/hover_card_component.rb +11 -0
  97. data/templates/components/input/input_component.html.erb +1 -0
  98. data/templates/components/input/input_component.rb +28 -0
  99. data/templates/components/invite_member_dialog/invite_member_dialog_component.html.erb +35 -0
  100. data/templates/components/invite_member_dialog/invite_member_dialog_component.rb +26 -0
  101. data/templates/components/label/label_component.html.erb +4 -0
  102. data/templates/components/label/label_component.rb +19 -0
  103. data/templates/components/link/link_component.html.erb +1 -0
  104. data/templates/components/link/link_component.rb +25 -0
  105. data/templates/components/masked_input/masked_input_component.html.erb +1 -0
  106. data/templates/components/masked_input/masked_input_component.rb +18 -0
  107. data/templates/components/native_select/native_select_component.html.erb +14 -0
  108. data/templates/components/native_select/native_select_component.rb +52 -0
  109. data/templates/components/page_header/page_header_component.html.erb +20 -0
  110. data/templates/components/page_header/page_header_component.rb +19 -0
  111. data/templates/components/pagination/pagination_component.html.erb +11 -0
  112. data/templates/components/pagination/pagination_component.rb +24 -0
  113. data/templates/components/popover/popover_component.html.erb +9 -0
  114. data/templates/components/popover/popover_component.rb +11 -0
  115. data/templates/components/progress/progress_component.html.erb +11 -0
  116. data/templates/components/progress/progress_component.rb +26 -0
  117. data/templates/components/radio_button/radio_button_component.html.erb +8 -0
  118. data/templates/components/radio_button/radio_button_component.rb +19 -0
  119. data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.html.erb +32 -0
  120. data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.rb +30 -0
  121. data/templates/components/search_input/search_input_component.html.erb +14 -0
  122. data/templates/components/search_input/search_input_component.rb +18 -0
  123. data/templates/components/select/select_component.html.erb +1 -0
  124. data/templates/components/select/select_component.rb +19 -0
  125. data/templates/components/separator/separator_component.html.erb +1 -0
  126. data/templates/components/separator/separator_component.rb +12 -0
  127. data/templates/components/settings_section/settings_section_component.html.erb +20 -0
  128. data/templates/components/settings_section/settings_section_component.rb +18 -0
  129. data/templates/components/sheet/sheet_component.html.erb +37 -0
  130. data/templates/components/sheet/sheet_component.rb +27 -0
  131. data/templates/components/shortcut_key/shortcut_key_component.html.erb +6 -0
  132. data/templates/components/shortcut_key/shortcut_key_component.rb +15 -0
  133. data/templates/components/sidebar/sidebar_component.html.erb +14 -0
  134. data/templates/components/sidebar/sidebar_component.rb +37 -0
  135. data/templates/components/skeleton/skeleton_component.html.erb +1 -0
  136. data/templates/components/skeleton/skeleton_component.rb +13 -0
  137. data/templates/components/stat_card/stat_card_component.html.erb +20 -0
  138. data/templates/components/stat_card/stat_card_component.rb +31 -0
  139. data/templates/components/switch/switch_component.html.erb +11 -0
  140. data/templates/components/switch/switch_component.rb +19 -0
  141. data/templates/components/table/table_component.html.erb +26 -0
  142. data/templates/components/table/table_component.rb +35 -0
  143. data/templates/components/tabs/tabs_component.html.erb +18 -0
  144. data/templates/components/tabs/tabs_component.rb +35 -0
  145. data/templates/components/team_member_list/team_member_list_component.html.erb +22 -0
  146. data/templates/components/team_member_list/team_member_list_component.rb +26 -0
  147. data/templates/components/textarea/textarea_component.html.erb +1 -0
  148. data/templates/components/textarea/textarea_component.rb +23 -0
  149. data/templates/components/theme_toggle/theme_toggle_component.html.erb +4 -0
  150. data/templates/components/theme_toggle/theme_toggle_component.rb +15 -0
  151. data/templates/components/tooltip/tooltip_component.html.erb +9 -0
  152. data/templates/components/tooltip/tooltip_component.rb +16 -0
  153. data/templates/components/top_nav/top_nav_component.html.erb +21 -0
  154. data/templates/components/top_nav/top_nav_component.rb +44 -0
  155. data/templates/components/typography/typography_component.html.erb +1 -0
  156. data/templates/components/typography/typography_component.rb +24 -0
  157. data/templates/controllers/accordion_controller.js +27 -0
  158. data/templates/controllers/alert_dialog_controller.js +38 -0
  159. data/templates/controllers/api_key_field_controller.js +36 -0
  160. data/templates/controllers/calendar_controller.js +16 -0
  161. data/templates/controllers/carousel_controller.js +50 -0
  162. data/templates/controllers/clipboard_controller.js +17 -0
  163. data/templates/controllers/collapsible_controller.js +13 -0
  164. data/templates/controllers/combobox_controller.js +64 -0
  165. data/templates/controllers/command_controller.js +80 -0
  166. data/templates/controllers/context_menu_controller.js +36 -0
  167. data/templates/controllers/data_table_controller.js +34 -0
  168. data/templates/controllers/date_picker_controller.js +17 -0
  169. data/templates/controllers/dialog_controller.js +50 -0
  170. data/templates/controllers/dropdown_menu_controller.js +92 -0
  171. data/templates/controllers/hover_card_controller.js +17 -0
  172. data/templates/controllers/invite_member_dialog_controller.js +28 -0
  173. data/templates/controllers/masked_input_controller.js +30 -0
  174. data/templates/controllers/popover_controller.js +42 -0
  175. data/templates/controllers/rich_text_editor_lite_controller.js +443 -0
  176. data/templates/controllers/select_controller.js +10 -0
  177. data/templates/controllers/sheet_controller.js +34 -0
  178. data/templates/controllers/sidebar_controller.js +10 -0
  179. data/templates/controllers/tabs_controller.js +41 -0
  180. data/templates/controllers/theme_toggle_controller.js +24 -0
  181. data/templates/controllers/tooltip_controller.js +10 -0
  182. 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
+ }