senren-ui 0.1.6 → 0.3.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 (101) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +215 -0
  3. data/CONTRIBUTING.md +41 -3
  4. data/README.md +138 -33
  5. data/Rakefile +14 -1
  6. data/docs/components.md +10 -10
  7. data/docs/hot_reload.md +103 -0
  8. data/docs/performance_testing.md +7 -3
  9. data/docs/visual_style.md +80 -0
  10. data/lib/generators/senren/component/templates/controller.js.tt +7 -4
  11. data/lib/generators/senren/install/install_generator.rb +77 -0
  12. data/lib/generators/senren/install/templates/base_component.rb.tt +142 -4
  13. data/lib/generators/senren/install/templates/conventions.md.tt +57 -1
  14. data/lib/generators/senren/install/templates/senren_themes.css.tt +339 -0
  15. data/lib/senren/rails/agent_rules_writer.rb +65 -19
  16. data/lib/senren/rails/asset_path_guard.rb +128 -0
  17. data/lib/senren/rails/base_component_patch.rb +64 -0
  18. data/lib/senren/rails/component_copier.rb +101 -50
  19. data/lib/senren/rails/component_installer.rb +26 -0
  20. data/lib/senren/rails/doctor.rb +7 -4
  21. data/lib/senren/rails/engine.rb +23 -0
  22. data/lib/senren/rails/host_paths.rb +11 -2
  23. data/lib/senren/rails/marker_block.rb +81 -0
  24. data/lib/senren/rails/registry.rb +10 -4
  25. data/lib/senren/rails/safe_write.rb +169 -0
  26. data/lib/senren/rails/skill_writer.rb +47 -11
  27. data/lib/senren/rails/version.rb +1 -1
  28. data/lib/senren/rails.rb +1 -1
  29. data/lib/senren-ui.rb +15 -0
  30. data/lib/tasks/senren.rake +43 -17
  31. data/registry/components.yml +45 -0
  32. data/registry/recipes.yml +12 -0
  33. data/templates/components/accordion/accordion_component.html.erb +3 -3
  34. data/templates/components/alert_dialog/alert_dialog_component.html.erb +6 -3
  35. data/templates/components/alert_dialog/alert_dialog_component.rb +5 -1
  36. data/templates/components/api_key_field/api_key_field_component.html.erb +1 -1
  37. data/templates/components/aspect_ratio/aspect_ratio_component.rb +7 -0
  38. data/templates/components/avatar/avatar_component.rb +8 -1
  39. data/templates/components/breadcrumb/breadcrumb_component.html.erb +8 -6
  40. data/templates/components/button/button_component.html.erb +1 -1
  41. data/templates/components/button/button_component.rb +27 -1
  42. data/templates/components/calendar/calendar_component.html.erb +2 -2
  43. data/templates/components/card/card_component.html.erb +4 -6
  44. data/templates/components/carousel/carousel_component.html.erb +1 -1
  45. data/templates/components/cart/cart_component.html.erb +67 -0
  46. data/templates/components/cart/cart_component.rb +71 -0
  47. data/templates/components/checkbox/checkbox_component.rb +1 -1
  48. data/templates/components/clipboard/clipboard_component.html.erb +3 -3
  49. data/templates/components/collapsible/collapsible_component.html.erb +3 -3
  50. data/templates/components/combobox/combobox_component.html.erb +3 -3
  51. data/templates/components/command/command_component.html.erb +1 -1
  52. data/templates/components/command/command_component.rb +1 -1
  53. data/templates/components/context_menu/context_menu_component.html.erb +6 -3
  54. data/templates/components/date_picker/date_picker_component.html.erb +3 -3
  55. data/templates/components/dialog/dialog_component.html.erb +7 -3
  56. data/templates/components/dialog/dialog_component.rb +12 -1
  57. data/templates/components/dropdown_menu/dropdown_menu_component.html.erb +6 -3
  58. data/templates/components/dropdown_menu/dropdown_menu_component.rb +57 -5
  59. data/templates/components/form/form_component.rb +9 -1
  60. data/templates/components/hover_card/hover_card_component.html.erb +6 -3
  61. data/templates/components/invite_member_dialog/invite_member_dialog_component.html.erb +3 -2
  62. data/templates/components/invite_member_dialog/invite_member_dialog_component.rb +1 -1
  63. data/templates/components/link/link_component.html.erb +1 -1
  64. data/templates/components/native_select/native_select_component.html.erb +2 -2
  65. data/templates/components/pagination/pagination_component.html.erb +2 -2
  66. data/templates/components/popover/popover_component.html.erb +6 -3
  67. data/templates/components/product_card/product_card_component.html.erb +38 -0
  68. data/templates/components/product_card/product_card_component.rb +49 -0
  69. data/templates/components/progress/progress_component.html.erb +2 -2
  70. data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.html.erb +1 -1
  71. data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.rb +1 -1
  72. data/templates/components/search_input/search_input_component.html.erb +2 -2
  73. data/templates/components/separator/separator_component.rb +7 -0
  74. data/templates/components/sheet/sheet_component.html.erb +13 -4
  75. data/templates/components/sheet/sheet_component.rb +12 -1
  76. data/templates/components/shortcut_key/shortcut_key_component.html.erb +2 -2
  77. data/templates/components/sidebar/sidebar_component.html.erb +2 -2
  78. data/templates/components/switch/switch_component.html.erb +1 -1
  79. data/templates/components/table/table_component.html.erb +2 -2
  80. data/templates/components/tabs/tabs_component.html.erb +3 -3
  81. data/templates/components/tooltip/tooltip_component.html.erb +5 -3
  82. data/templates/components/tooltip/tooltip_component.rb +2 -2
  83. data/templates/components/top_nav/top_nav_component.html.erb +2 -2
  84. data/templates/components/typography/typography_component.rb +7 -0
  85. data/templates/controllers/accordion_controller.js +1 -1
  86. data/templates/controllers/alert_dialog_controller.js +31 -7
  87. data/templates/controllers/cart_controller.js +85 -0
  88. data/templates/controllers/clipboard_controller.js +12 -1
  89. data/templates/controllers/command_controller.js +3 -4
  90. data/templates/controllers/context_menu_controller.js +38 -11
  91. data/templates/controllers/data_table_controller.js +8 -3
  92. data/templates/controllers/dialog_controller.js +47 -21
  93. data/templates/controllers/dropdown_menu_controller.js +40 -27
  94. data/templates/controllers/hover_card_controller.js +8 -0
  95. data/templates/controllers/invite_member_dialog_controller.js +6 -0
  96. data/templates/controllers/masked_input_controller.js +8 -1
  97. data/templates/controllers/popover_controller.js +25 -10
  98. data/templates/controllers/rich_text_editor_lite_controller.js +165 -28
  99. data/templates/controllers/sheet_controller.js +41 -11
  100. metadata +14 -17
  101. data/lib/senren/rails/installer.rb +0 -85
@@ -2,35 +2,36 @@ import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  // senren--dropdown-menu
4
4
  // Local UI: toggle visibility, close on outside click and Escape, basic arrow nav.
5
+ //
6
+ // State lives in `openValue`. Actions assign it; openValueChanged performs
7
+ // every DOM change — including the `data-state` attributes the chevron styling
8
+ // reads — and owns the document listeners.
5
9
  export default class extends Controller {
6
10
  static targets = ["trigger", "menu"]
11
+ static values = { open: Boolean }
7
12
 
8
13
  connect() {
9
14
  this._onDocClick = this._onDocClick.bind(this)
10
15
  this._onKey = this._onKey.bind(this)
11
- this._setOpenState(false)
12
16
  }
13
17
 
14
18
  disconnect() {
15
- document.removeEventListener("click", this._onDocClick)
16
- document.removeEventListener("keydown", this._onKey)
19
+ this._stopListening()
17
20
  }
18
21
 
19
22
  toggle(event) {
20
23
  event?.preventDefault()
21
- this.menuTarget.hidden ? this._show() : this.close()
24
+ this.openValue = !this.openValue
22
25
  }
23
26
 
24
27
  close() {
25
- this._setOpenState(false)
26
- document.removeEventListener("click", this._onDocClick)
27
- document.removeEventListener("keydown", this._onKey)
28
+ this.openValue = false
28
29
  }
29
30
 
30
31
  onTriggerKey(event) {
31
32
  if (["Enter", " ", "ArrowDown"].includes(event.key)) {
32
33
  event.preventDefault()
33
- this._show()
34
+ this.openValue = true
34
35
  this._focusItem(0)
35
36
  }
36
37
  }
@@ -39,32 +40,44 @@ export default class extends Controller {
39
40
  const items = this._items()
40
41
  const idx = items.indexOf(document.activeElement)
41
42
  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?.() }
43
+ if (event.key === "ArrowUp") { event.preventDefault(); this._focusItem((idx - 1 + items.length) % items.length) }
44
+ if (event.key === "Escape") { this.close(); this.triggerTarget?.focus?.() }
44
45
  }
45
46
 
46
- _show() {
47
- this._setOpenState(true)
48
- document.addEventListener("click", this._onDocClick)
49
- document.addEventListener("keydown", this._onKey)
50
- }
47
+ openValueChanged(isOpen) {
48
+ const state = isOpen ? "open" : "closed"
51
49
 
52
- _setOpenState(open) {
53
- const state = open ? "open" : "closed"
54
- this.menuTarget.hidden = !open
50
+ if (this.hasMenuTarget) this.menuTarget.hidden = !isOpen
55
51
  this.element.dataset.state = state
56
- this.triggerTarget.dataset.state = state
57
- this.triggerTarget.setAttribute("aria-expanded", open ? "true" : "false")
58
52
 
59
- const triggerControl = this._triggerControl()
60
- if (triggerControl && triggerControl !== this.triggerTarget) {
61
- triggerControl.dataset.state = state
62
- triggerControl.setAttribute("aria-expanded", open ? "true" : "false")
53
+ if (this.hasTriggerTarget) {
54
+ this.triggerTarget.dataset.state = state
55
+ this.triggerTarget.setAttribute("aria-expanded", isOpen ? "true" : "false")
56
+
57
+ const control = this._triggerControl()
58
+ if (control && control !== this.triggerTarget) {
59
+ control.dataset.state = state
60
+ control.setAttribute("aria-expanded", isOpen ? "true" : "false")
61
+ }
62
+
63
+ this.triggerTarget.querySelectorAll("[data-senren-chevron]").forEach((chevron) => {
64
+ chevron.dataset.state = state
65
+ })
63
66
  }
64
67
 
65
- this.triggerTarget.querySelectorAll("[data-senren-chevron]").forEach((chevron) => {
66
- chevron.dataset.state = state
67
- })
68
+ if (isOpen) {
69
+ document.addEventListener("click", this._onDocClick)
70
+ document.addEventListener("keydown", this._onKey)
71
+ this.dispatch("opened")
72
+ return
73
+ }
74
+
75
+ this._stopListening()
76
+ }
77
+
78
+ _stopListening() {
79
+ document.removeEventListener("click", this._onDocClick)
80
+ document.removeEventListener("keydown", this._onKey)
68
81
  }
69
82
 
70
83
  _triggerControl() {
@@ -14,4 +14,12 @@ export default class extends Controller {
14
14
  clearTimeout(this._showTimer)
15
15
  this._hideTimer = setTimeout(() => { if (this.hasPanelTarget) this.panelTarget.hidden = true }, 120)
16
16
  }
17
+
18
+ // Either timer can still be pending when a Turbo navigation removes the
19
+ // element, which retains this controller and its detached subtree until it
20
+ // fires.
21
+ disconnect() {
22
+ clearTimeout(this._showTimer)
23
+ clearTimeout(this._hideTimer)
24
+ }
17
25
  }
@@ -8,6 +8,12 @@ export default class extends Controller {
8
8
  this.closeOnEscape = this.closeOnEscape.bind(this)
9
9
  }
10
10
 
11
+ // open() registers a document-level listener; without this the listener and
12
+ // the detached subtree survive every Turbo navigation made while open.
13
+ disconnect() {
14
+ document.removeEventListener("keydown", this.closeOnEscape)
15
+ }
16
+
11
17
  open() {
12
18
  this.overlayTarget.hidden = false
13
19
  this.panelTarget.hidden = false
@@ -10,7 +10,14 @@ export default class extends Controller {
10
10
  static values = { mask: String }
11
11
 
12
12
  connect() {
13
- this.element.addEventListener("input", this._onInput.bind(this))
13
+ // Keep the bound reference: Stimulus re-connects on Turbo morph/restore,
14
+ // and an unremovable listener would stack up one mask pass per cycle.
15
+ this._boundOnInput = this._onInput.bind(this)
16
+ this.element.addEventListener("input", this._boundOnInput)
17
+ }
18
+
19
+ disconnect() {
20
+ this.element.removeEventListener("input", this._boundOnInput)
14
21
  }
15
22
 
16
23
  _onInput(event) {
@@ -2,8 +2,13 @@ import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  // senren--popover
4
4
  // Toggles a small panel; closes on outside click and Escape.
5
+ //
6
+ // State lives in `openValue`. Actions assign it; openValueChanged performs
7
+ // every DOM change and owns the document listeners, so the open state is
8
+ // server-renderable and survives a Turbo morph.
5
9
  export default class extends Controller {
6
10
  static targets = ["trigger", "panel"]
11
+ static values = { open: Boolean }
7
12
 
8
13
  connect() {
9
14
  this._onDocClick = this._onDocClick.bind(this)
@@ -11,25 +16,35 @@ export default class extends Controller {
11
16
  }
12
17
 
13
18
  disconnect() {
14
- document.removeEventListener("click", this._onDocClick)
15
- document.removeEventListener("keydown", this._onKey)
19
+ this._stopListening()
16
20
  }
17
21
 
18
22
  toggle(event) {
19
23
  event?.preventDefault()
20
- this.panelTarget.hidden ? this._show() : this.close()
24
+ this.openValue = !this.openValue
21
25
  }
22
26
 
23
27
  close() {
24
- this.panelTarget.hidden = true
25
- document.removeEventListener("click", this._onDocClick)
26
- document.removeEventListener("keydown", this._onKey)
28
+ this.openValue = false
29
+ }
30
+
31
+ openValueChanged(isOpen) {
32
+ if (this.hasPanelTarget) this.panelTarget.hidden = !isOpen
33
+ if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", isOpen ? "true" : "false")
34
+
35
+ if (isOpen) {
36
+ document.addEventListener("click", this._onDocClick)
37
+ document.addEventListener("keydown", this._onKey)
38
+ this.dispatch("opened")
39
+ return
40
+ }
41
+
42
+ this._stopListening()
27
43
  }
28
44
 
29
- _show() {
30
- this.panelTarget.hidden = false
31
- document.addEventListener("click", this._onDocClick)
32
- document.addEventListener("keydown", this._onKey)
45
+ _stopListening() {
46
+ document.removeEventListener("click", this._onDocClick)
47
+ document.removeEventListener("keydown", this._onKey)
33
48
  }
34
49
 
35
50
  _onDocClick(event) {
@@ -2,6 +2,16 @@ import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  const ALLOWED_LINK_PROTOCOLS = new Set(["http:", "https:", "mailto:", "tel:"])
4
4
 
5
+ // Mirrors the server-side sanitize() allowlist in
6
+ // rich_text_editor_lite_component.html.erb, so both ends agree on what the
7
+ // stored value may contain.
8
+ const ALLOWED_PASTE_TAGS = new Set([
9
+ "P", "BR", "STRONG", "B", "EM", "I", "A", "UL", "OL", "LI", "H1", "H2", "H3"
10
+ ])
11
+ const ALLOWED_PASTE_ATTRIBUTES = new Set(["href", "rel", "target", "data-align"])
12
+ // Dropped whole rather than unwrapped: their text content is code, not prose.
13
+ const DROPPED_PASTE_TAGS = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "IFRAME", "OBJECT", "EMBED"])
14
+
5
15
  // senren--rich-text-editor-lite
6
16
  // Local UI: tiny contenteditable toolbar synced to a hidden textarea.
7
17
  export default class extends Controller {
@@ -17,7 +27,7 @@ export default class extends Controller {
17
27
  this.sync()
18
28
  this.rememberSelection()
19
29
  this.updateToolbar()
20
- this.debug("connect", this.snapshot())
30
+ this.debug("connect", () => (this.snapshot()))
21
31
  }
22
32
 
23
33
  keepSelection(event) {
@@ -33,7 +43,12 @@ export default class extends Controller {
33
43
  if (!event.metaKey && !event.ctrlKey) return
34
44
 
35
45
  event.preventDefault()
36
- window.open(link.href, "_blank", "noopener,noreferrer")
46
+ // Anchors can arrive from a paste, so gate the protocol here too rather
47
+ // than trusting the browser to refuse javascript:/data: in window.open.
48
+ const href = this.normalizeHref(link.getAttribute("href"))
49
+ if (!href) return
50
+
51
+ window.open(href, "_blank", "noopener,noreferrer")
37
52
  }
38
53
 
39
54
  format(event) {
@@ -41,11 +56,11 @@ export default class extends Controller {
41
56
  const command = event.currentTarget.dataset.command
42
57
  if (!command) return
43
58
 
44
- this.debug("format:start", { command, before: this.snapshot() })
59
+ this.debug("format:start", () => ({ command, before: this.snapshot() }))
45
60
  this.restoreSelection()
46
61
  if (command === "createLink") {
47
62
  const url = window.prompt("Paste a URL")
48
- this.debug("createLink:prompt", { url, afterPrompt: this.snapshot() })
63
+ this.debug("createLink:prompt", () => ({ url, afterPrompt: this.snapshot() }))
49
64
  if (!url) return
50
65
  this.restoreSelection()
51
66
  this.insertLink(url)
@@ -65,7 +80,7 @@ export default class extends Controller {
65
80
  this.sync()
66
81
  this.updateToolbar()
67
82
  this.rememberSelection()
68
- this.debug("format:done", { command, after: this.snapshot() })
83
+ this.debug("format:done", () => ({ command, after: this.snapshot() }))
69
84
  }
70
85
 
71
86
  sync() {
@@ -77,6 +92,96 @@ export default class extends Controller {
77
92
  requestAnimationFrame(() => this.sync())
78
93
  }
79
94
 
95
+ // Handles both paste and drop. The browser's own contenteditable paste path
96
+ // keeps on* handler attributes, so pasted markup must be rebuilt against an
97
+ // allowlist before it reaches the document.
98
+ paste(event) {
99
+ const transfer = event.clipboardData || event.dataTransfer
100
+ if (!transfer) return
101
+
102
+ event.preventDefault()
103
+ const html = transfer.getData("text/html")
104
+ const text = transfer.getData("text/plain")
105
+
106
+ if (html) {
107
+ this.insertSanitized(this.sanitizeToFragment(html))
108
+ } else if (text) {
109
+ document.execCommand("insertText", false, text)
110
+ }
111
+
112
+ this.sync()
113
+ this.rememberSelection()
114
+ this.updateToolbar()
115
+ }
116
+
117
+ insertSanitized(fragment) {
118
+ if (fragment.childNodes.length === 0) return
119
+
120
+ const range = this.activeRange()
121
+ if (!range) {
122
+ this.editorTarget.appendChild(fragment)
123
+ return
124
+ }
125
+
126
+ const last = fragment.lastChild
127
+ range.deleteContents()
128
+ range.insertNode(fragment)
129
+ if (last) {
130
+ range.setStartAfter(last)
131
+ range.collapse(true)
132
+ const selection = window.getSelection()
133
+ selection.removeAllRanges()
134
+ selection.addRange(range)
135
+ }
136
+ }
137
+
138
+ // DOMParser builds an inert document: no scripts run and no resources load,
139
+ // so onerror/onload never fire while we walk the tree.
140
+ sanitizeToFragment(html) {
141
+ const parsed = new DOMParser().parseFromString(html, "text/html")
142
+ return this.sanitizeChildren(parsed.body)
143
+ }
144
+
145
+ sanitizeChildren(node) {
146
+ const fragment = document.createDocumentFragment()
147
+ Array.from(node.childNodes).forEach((child) => {
148
+ const clean = this.sanitizeNode(child)
149
+ if (clean) fragment.appendChild(clean)
150
+ })
151
+ return fragment
152
+ }
153
+
154
+ sanitizeNode(node) {
155
+ if (node.nodeType === Node.TEXT_NODE) return document.createTextNode(node.nodeValue)
156
+ if (node.nodeType !== Node.ELEMENT_NODE) return null
157
+
158
+ const tag = node.tagName.toUpperCase()
159
+ if (DROPPED_PASTE_TAGS.has(tag)) return null
160
+ // Other disallowed elements are unwrapped, not dropped: keep their text.
161
+ if (!ALLOWED_PASTE_TAGS.has(tag)) return this.sanitizeChildren(node)
162
+
163
+ const element = document.createElement(tag.toLowerCase())
164
+ Array.from(node.attributes).forEach((attr) => {
165
+ const name = attr.name.toLowerCase()
166
+ if (!ALLOWED_PASTE_ATTRIBUTES.has(name)) return
167
+
168
+ if (name === "href") {
169
+ const href = this.normalizeHref(attr.value)
170
+ if (href) element.setAttribute("href", href)
171
+ return
172
+ }
173
+
174
+ element.setAttribute(name, attr.value)
175
+ })
176
+
177
+ if (tag === "A" && element.hasAttribute("target")) {
178
+ element.setAttribute("rel", "noopener noreferrer")
179
+ }
180
+
181
+ element.appendChild(this.sanitizeChildren(node))
182
+ return element
183
+ }
184
+
80
185
  updateToolbar() {
81
186
  this.buttonTargets.forEach((button) => {
82
187
  const command = button.dataset.command
@@ -122,9 +227,9 @@ export default class extends Controller {
122
227
  const range = selection.getRangeAt(0)
123
228
  if (this.rangeInsideEditor(range)) {
124
229
  this.savedRange = range.cloneRange()
125
- this.debug("rememberSelection:saved", this.describeRange(this.savedRange))
230
+ this.debug("rememberSelection:saved", () => (this.describeRange(this.savedRange)))
126
231
  } else {
127
- this.debug("rememberSelection:outside", this.describeRange(range))
232
+ this.debug("rememberSelection:outside", () => (this.describeRange(range)))
128
233
  }
129
234
  }
130
235
 
@@ -134,17 +239,17 @@ export default class extends Controller {
134
239
  const selection = window.getSelection()
135
240
  selection.removeAllRanges()
136
241
  selection.addRange(this.savedRange)
137
- this.debug("restoreSelection:saved", this.describeRange(this.savedRange))
242
+ this.debug("restoreSelection:saved", () => (this.describeRange(this.savedRange)))
138
243
  return
139
244
  }
140
245
 
141
- this.debug("restoreSelection:endFallback", this.snapshot())
246
+ this.debug("restoreSelection:endFallback", () => (this.snapshot()))
142
247
  this.placeCaretAtEnd()
143
248
  }
144
249
 
145
250
  insertLink(rawUrl) {
146
251
  const url = this.normalizeUrl(rawUrl)
147
- this.debug("insertLink:start", { rawUrl, url, before: this.snapshot() })
252
+ this.debug("insertLink:start", () => ({ rawUrl, url, before: this.snapshot() }))
148
253
  if (!url) return
149
254
 
150
255
  const range = this.activeRange()
@@ -157,7 +262,7 @@ export default class extends Controller {
157
262
  anchor.href = url
158
263
  anchor.rel = "noopener noreferrer"
159
264
  anchor.target = "_blank"
160
- this.debug("insertLink:range", this.describeRange(range))
265
+ this.debug("insertLink:range", () => (this.describeRange(range)))
161
266
 
162
267
  if (range.collapsed) {
163
268
  anchor.textContent = url
@@ -172,12 +277,12 @@ export default class extends Controller {
172
277
  const selection = window.getSelection()
173
278
  selection.removeAllRanges()
174
279
  selection.addRange(range)
175
- this.debug("insertLink:done", { anchor: anchor.outerHTML, after: this.snapshot() })
280
+ this.debug("insertLink:done", () => ({ anchor: anchor.outerHTML, after: this.snapshot() }))
176
281
  }
177
282
 
178
283
  toggleList(tagName) {
179
284
  const range = this.activeRange()
180
- this.debug("toggleList:start", { tagName, range: this.describeRange(range), before: this.snapshot() })
285
+ this.debug("toggleList:start", () => ({ tagName, range: this.describeRange(range), before: this.snapshot() }))
181
286
  if (!range) return
182
287
 
183
288
  const activeList = this.closestElement(range.startContainer, "ul, ol")
@@ -187,12 +292,12 @@ export default class extends Controller {
187
292
  } else {
188
293
  this.convertList(activeList, tagName)
189
294
  }
190
- this.debug("toggleList:existingList", { tagName, after: this.snapshot() })
295
+ this.debug("toggleList:existingList", () => ({ tagName, after: this.snapshot() }))
191
296
  return
192
297
  }
193
298
 
194
299
  const blocks = this.selectedBlocks(range)
195
- this.debug("toggleList:blocks", { tagName, blocks: blocks.map((block) => block.outerHTML) })
300
+ this.debug("toggleList:blocks", () => ({ tagName, blocks: blocks.map((block) => block.outerHTML) }))
196
301
  if (blocks.length === 0) return
197
302
 
198
303
  const list = document.createElement(tagName)
@@ -204,15 +309,15 @@ export default class extends Controller {
204
309
  })
205
310
 
206
311
  blocks[0].before(list)
207
- blocks.forEach((block) => block.remove())
312
+ blocks.forEach((block) => { block.remove() })
208
313
  this.selectNodeContents(list)
209
- this.debug("toggleList:done", { list: list.outerHTML, after: this.snapshot() })
314
+ this.debug("toggleList:done", () => ({ list: list.outerHTML, after: this.snapshot() }))
210
315
  }
211
316
 
212
317
  formatBlocks(tagName) {
213
318
  const normalizedTagName = this.normalizedBlockTag(tagName)
214
319
  const range = this.activeRange()
215
- this.debug("formatBlocks:start", { tagName: normalizedTagName, range: this.describeRange(range) })
320
+ this.debug("formatBlocks:start", () => ({ tagName: normalizedTagName, range: this.describeRange(range) }))
216
321
  if (!range) return
217
322
 
218
323
  const blocks = this.selectedBlocks(range)
@@ -220,13 +325,13 @@ export default class extends Controller {
220
325
 
221
326
  const replacements = blocks.map((block) => this.replaceBlock(block, normalizedTagName))
222
327
  this.selectNodeContents(replacements[replacements.length - 1])
223
- this.debug("formatBlocks:done", { tagName: normalizedTagName, blocks: replacements.map((block) => block.outerHTML) })
328
+ this.debug("formatBlocks:done", () => ({ tagName: normalizedTagName, blocks: replacements.map((block) => block.outerHTML) }))
224
329
  }
225
330
 
226
331
  alignBlocks(alignment) {
227
332
  const normalizedAlignment = this.normalizedAlignment(alignment)
228
333
  const range = this.activeRange()
229
- this.debug("alignBlocks:start", { alignment: normalizedAlignment, range: this.describeRange(range) })
334
+ this.debug("alignBlocks:start", () => ({ alignment: normalizedAlignment, range: this.describeRange(range) }))
230
335
  if (!range) return
231
336
 
232
337
  const blocks = this.selectedBlocks(range)
@@ -239,7 +344,7 @@ export default class extends Controller {
239
344
  })
240
345
 
241
346
  if (blocks.length > 0) this.selectNodeContents(blocks[blocks.length - 1])
242
- this.debug("alignBlocks:done", { alignment: normalizedAlignment, blocks: blocks.map((block) => block.outerHTML) })
347
+ this.debug("alignBlocks:done", () => ({ alignment: normalizedAlignment, blocks: blocks.map((block) => block.outerHTML) }))
243
348
  }
244
349
 
245
350
  unwrapList(list) {
@@ -279,11 +384,41 @@ export default class extends Controller {
279
384
  }
280
385
  }
281
386
 
387
+ // Shapes that are unsafe in every context. Browsers treat "\" as "/" and
388
+ // strip TAB/CR/LF before parsing, and any leading "//" is protocol-relative
389
+ // however many slashes follow — "///evil.example" resolves to
390
+ // https://evil.example/.
391
+ rejectedUrlShape(url) {
392
+ if (url.length === 0) return true
393
+ if (url.includes("\\")) return true
394
+ if ([...url].some((ch) => ch.charCodeAt(0) <= 0x1f || ch.charCodeAt(0) === 0x7f)) return true
395
+
396
+ return url.startsWith("//")
397
+ }
398
+
399
+ // Policy for an href that already exists in markup — a pasted anchor, or one
400
+ // being opened. It mirrors safe_url on the server: a relative reference stays
401
+ // relative, because in HTML `href="settings"` means "relative to this page",
402
+ // not "the host settings". Promoting it to https://settings/ would turn a
403
+ // same-origin link into an off-origin one.
404
+ normalizeHref(rawUrl) {
405
+ const url = String(rawUrl || "").trim()
406
+ if (this.rejectedUrlShape(url)) return null
407
+ if (url.startsWith("#") || url.startsWith("/")) return url
408
+
409
+ const scheme = url.match(/^[a-z][a-z0-9+.-]*:/i)
410
+ if (!scheme) return url
411
+
412
+ return ALLOWED_LINK_PROTOCOLS.has(scheme[0].toLowerCase()) ? url : null
413
+ }
414
+
415
+ // Policy for a URL a person typed into the link prompt, where a bare host is
416
+ // the common case and should become https://host/. Only ever applied to input
417
+ // the user just entered, never to markup.
282
418
  normalizeUrl(rawUrl) {
283
419
  const url = String(rawUrl || "").trim()
284
- if (url.length === 0) return null
285
- if (url.startsWith("#")) return url
286
- if (url.startsWith("/") && !url.startsWith("//")) return url
420
+ if (this.rejectedUrlShape(url)) return null
421
+ if (url.startsWith("#") || url.startsWith("/")) return url
287
422
 
288
423
  const candidate = /^[a-z][a-z0-9+.-]*:/i.test(url) ? url : `https://${url}`
289
424
  try {
@@ -299,19 +434,19 @@ export default class extends Controller {
299
434
  if (selection && selection.rangeCount > 0) {
300
435
  const range = selection.getRangeAt(0)
301
436
  if (this.rangeInsideEditor(range)) {
302
- this.debug("activeRange:selection", this.describeRange(range))
437
+ this.debug("activeRange:selection", () => (this.describeRange(range)))
303
438
  return range
304
439
  }
305
440
  }
306
441
 
307
442
  if (this.savedRange && this.rangeInsideEditor(this.savedRange)) {
308
- this.debug("activeRange:saved", this.describeRange(this.savedRange))
443
+ this.debug("activeRange:saved", () => (this.describeRange(this.savedRange)))
309
444
  return this.savedRange
310
445
  }
311
446
 
312
447
  this.placeCaretAtEnd()
313
448
  const range = window.getSelection().getRangeAt(0)
314
- this.debug("activeRange:endFallback", this.describeRange(range))
449
+ this.debug("activeRange:endFallback", () => (this.describeRange(range)))
315
450
  return range
316
451
  }
317
452
 
@@ -445,9 +580,11 @@ export default class extends Controller {
445
580
  return `${node.tagName.toLowerCase()}${id}${classes}`
446
581
  }
447
582
 
583
+ // `detail` may be a thunk so callers can defer building expensive payloads
584
+ // (full innerHTML/textContent snapshots) until debugging is actually on.
448
585
  debug(message, detail = {}) {
449
586
  if (!this.debugValue) return
450
587
 
451
- console.debug("[senren rich text]", message, detail)
588
+ console.debug("[senren rich text]", message, typeof detail === "function" ? detail() : detail)
452
589
  }
453
590
  }
@@ -2,33 +2,63 @@ import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  // senren--sheet
4
4
  // Side panel; behaves like a dialog.
5
+ //
6
+ // State lives in `openValue`. Actions assign it; openValueChanged performs every
7
+ // DOM change, so the panel can be opened from the server and survives a morph.
5
8
  export default class extends Controller {
6
9
  static targets = ["overlay", "panel", "trigger"]
10
+ static values = { open: Boolean }
11
+
12
+ connect() {
13
+ this._onKey = this._onKey.bind(this)
14
+ this._onBeforeCache = this._onBeforeCache.bind(this)
15
+ document.addEventListener("turbo:before-cache", this._onBeforeCache)
16
+ }
7
17
 
8
- connect() { this._onKey = this._onKey.bind(this) }
9
18
  disconnect() {
10
19
  document.removeEventListener("keydown", this._onKey)
20
+ document.removeEventListener("turbo:before-cache", this._onBeforeCache)
11
21
  document.body.style.overflow = ""
12
22
  }
13
23
 
14
24
  open(event) {
15
25
  event?.preventDefault()
16
- this.overlayTarget.hidden = false
17
- this.panelTarget.hidden = false
18
- this.panelTarget.dataset.open = "true"
19
- document.addEventListener("keydown", this._onKey)
20
- document.body.style.overflow = "hidden"
21
- queueMicrotask(() => this.panelTarget?.focus())
26
+ this.openValue = true
22
27
  }
23
28
 
24
29
  close(event) {
25
30
  event?.preventDefault()
26
- this.overlayTarget.hidden = true
27
- this.panelTarget.hidden = true
28
- this.panelTarget.dataset.open = "false"
31
+ this.openValue = false
32
+ }
33
+
34
+ openValueChanged(isOpen, wasOpen) {
35
+ if (this.hasOverlayTarget) this.overlayTarget.hidden = !isOpen
36
+ if (this.hasPanelTarget) {
37
+ this.panelTarget.hidden = !isOpen
38
+ this.panelTarget.dataset.open = isOpen ? "true" : "false"
39
+ }
40
+
41
+ if (isOpen) {
42
+ document.addEventListener("keydown", this._onKey)
43
+ document.body.style.overflow = "hidden"
44
+ queueMicrotask(() => this.panelTarget?.focus())
45
+ this.dispatch("opened")
46
+ return
47
+ }
48
+
29
49
  document.removeEventListener("keydown", this._onKey)
30
50
  document.body.style.overflow = ""
51
+ if (wasOpen !== undefined) this.dispatch("closed")
52
+ }
53
+
54
+ _onKey(event) {
55
+ if (event.key === "Escape") this.close()
31
56
  }
32
57
 
33
- _onKey(event) { if (event.key === "Escape") this.close() }
58
+ // Turbo snapshots the page before navigating away; without this the scroll
59
+ // lock is captured while applied and a back navigation restores a page that
60
+ // cannot be scrolled.
61
+ _onBeforeCache() {
62
+ this.openValue = false
63
+ }
34
64
  }