senren-ui 0.1.5 → 0.2.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 +4 -4
- data/CHANGELOG.md +124 -2
- data/CONTRIBUTING.md +79 -9
- data/README.md +149 -14
- data/Rakefile +14 -1
- data/docs/components.md +222 -0
- data/docs/hot_reload.md +103 -0
- data/docs/performance_testing.md +38 -0
- data/lib/commands/senren/add/add_command.rb +35 -0
- data/lib/generators/senren/component/templates/controller.js.tt +7 -4
- data/lib/generators/senren/install/install_generator.rb +74 -0
- data/lib/generators/senren/install/templates/base_component.rb.tt +89 -8
- data/lib/generators/senren/install/templates/conventions.md.tt +26 -7
- data/lib/senren/rails/agent_rules_writer.rb +62 -19
- data/lib/senren/rails/asset_path_guard.rb +128 -0
- data/lib/senren/rails/base_component_patch.rb +64 -0
- data/lib/senren/rails/component_copier.rb +138 -18
- data/lib/senren/rails/component_installer.rb +73 -0
- data/lib/senren/rails/doctor.rb +7 -4
- data/lib/senren/rails/engine.rb +23 -0
- data/lib/senren/rails/host_paths.rb +11 -2
- data/lib/senren/rails/marker_block.rb +81 -0
- data/lib/senren/rails/registry.rb +73 -35
- data/lib/senren/rails/safe_write.rb +169 -0
- data/lib/senren/rails/skill_writer.rb +48 -12
- data/lib/senren/rails/version.rb +1 -1
- data/lib/senren/rails.rb +2 -1
- data/lib/senren-ui.rb +15 -0
- data/lib/tasks/senren.rake +53 -34
- data/registry/components.yml +45 -0
- data/registry/recipes.yml +12 -0
- data/templates/components/alert_dialog/alert_dialog_component.rb +1 -1
- data/templates/components/api_key_field/api_key_field_component.html.erb +1 -1
- data/templates/components/aspect_ratio/aspect_ratio_component.rb +7 -0
- data/templates/components/avatar/avatar_component.rb +8 -1
- data/templates/components/billing_plan_card/billing_plan_card_component.html.erb +1 -1
- data/templates/components/breadcrumb/breadcrumb_component.rb +2 -2
- data/templates/components/button/button_component.html.erb +1 -1
- data/templates/components/carousel/carousel_component.rb +1 -1
- data/templates/components/cart/cart_component.html.erb +61 -0
- data/templates/components/cart/cart_component.rb +71 -0
- data/templates/components/checkbox/checkbox_component.rb +1 -1
- data/templates/components/clipboard/clipboard_component.html.erb +1 -1
- data/templates/components/command/command_component.rb +2 -2
- data/templates/components/date_picker/date_picker_component.html.erb +1 -1
- data/templates/components/dialog/dialog_component.rb +1 -1
- data/templates/components/dropdown_menu/dropdown_menu_component.rb +10 -7
- data/templates/components/form/form_component.html.erb +8 -1
- data/templates/components/form/form_component.rb +12 -2
- data/templates/components/input/input_component.html.erb +1 -1
- data/templates/components/input/input_component.rb +19 -0
- data/templates/components/invite_member_dialog/invite_member_dialog_component.rb +1 -1
- data/templates/components/label/label_component.html.erb +1 -2
- data/templates/components/label/label_component.rb +12 -2
- data/templates/components/link/link_component.html.erb +1 -1
- data/templates/components/native_select/native_select_component.html.erb +19 -5
- data/templates/components/native_select/native_select_component.rb +17 -5
- data/templates/components/pagination/pagination_component.rb +2 -1
- data/templates/components/product_card/product_card_component.html.erb +38 -0
- data/templates/components/product_card/product_card_component.rb +49 -0
- data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.html.erb +1 -1
- data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.rb +1 -1
- data/templates/components/separator/separator_component.rb +7 -0
- data/templates/components/sheet/sheet_component.rb +1 -1
- data/templates/components/sidebar/sidebar_component.rb +2 -2
- data/templates/components/switch/switch_component.html.erb +2 -2
- data/templates/components/tooltip/tooltip_component.rb +2 -2
- data/templates/components/top_nav/top_nav_component.rb +2 -2
- data/templates/components/typography/typography_component.rb +7 -0
- data/templates/controllers/accordion_controller.js +1 -1
- data/templates/controllers/alert_dialog_controller.js +31 -7
- data/templates/controllers/cart_controller.js +83 -0
- data/templates/controllers/clipboard_controller.js +12 -1
- data/templates/controllers/command_controller.js +3 -4
- data/templates/controllers/context_menu_controller.js +38 -11
- data/templates/controllers/data_table_controller.js +8 -3
- data/templates/controllers/dialog_controller.js +47 -21
- data/templates/controllers/dropdown_menu_controller.js +40 -27
- data/templates/controllers/hover_card_controller.js +8 -0
- data/templates/controllers/invite_member_dialog_controller.js +6 -0
- data/templates/controllers/masked_input_controller.js +8 -1
- data/templates/controllers/popover_controller.js +25 -10
- data/templates/controllers/rich_text_editor_lite_controller.js +175 -28
- data/templates/controllers/sheet_controller.js +41 -11
- metadata +20 -6
- data/lib/senren/rails/installer.rb +0 -84
|
@@ -1,50 +1,76 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus"
|
|
2
2
|
|
|
3
3
|
// senren--dialog
|
|
4
|
-
// Local UI: open/close,
|
|
4
|
+
// Local UI: open/close, Escape to close, body scroll lock.
|
|
5
|
+
//
|
|
6
|
+
// State lives in `openValue`, not in the DOM nodes this controller touches.
|
|
7
|
+
// Actions only assign the value; every DOM change happens in openValueChanged.
|
|
8
|
+
//
|
|
9
|
+
// That inversion is what makes the state server-renderable (render
|
|
10
|
+
// open-value="true" and the dialog is open with no JavaScript), survivable
|
|
11
|
+
// across a Turbo morph, and reachable from a Turbo Stream that changes a single
|
|
12
|
+
// attribute. It is the pattern every stateful Senren controller follows.
|
|
5
13
|
export default class extends Controller {
|
|
6
14
|
static targets = ["overlay", "panel", "trigger"]
|
|
7
|
-
static values
|
|
15
|
+
static values = { open: Boolean }
|
|
8
16
|
|
|
9
17
|
connect() {
|
|
10
18
|
this._onKey = this._onKey.bind(this)
|
|
11
|
-
|
|
19
|
+
this._onBeforeCache = this._onBeforeCache.bind(this)
|
|
20
|
+
document.addEventListener("turbo:before-cache", this._onBeforeCache)
|
|
12
21
|
}
|
|
13
22
|
|
|
14
23
|
disconnect() {
|
|
15
24
|
document.removeEventListener("keydown", this._onKey)
|
|
16
|
-
document.
|
|
25
|
+
document.removeEventListener("turbo:before-cache", this._onBeforeCache)
|
|
26
|
+
this._releaseScroll()
|
|
17
27
|
}
|
|
18
28
|
|
|
19
29
|
open(event) {
|
|
20
30
|
event?.preventDefault()
|
|
21
|
-
this.
|
|
31
|
+
this.openValue = true
|
|
22
32
|
}
|
|
23
33
|
|
|
24
34
|
close(event) {
|
|
25
35
|
event?.preventDefault()
|
|
26
|
-
this.
|
|
36
|
+
this.openValue = false
|
|
27
37
|
}
|
|
28
38
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (this.
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
39
|
+
// Stimulus calls this during initialization too, so the server-rendered value
|
|
40
|
+
// paints the first frame.
|
|
41
|
+
openValueChanged(isOpen, wasOpen) {
|
|
42
|
+
if (this.hasOverlayTarget) this.overlayTarget.hidden = !isOpen
|
|
43
|
+
if (this.hasPanelTarget) this.panelTarget.hidden = !isOpen
|
|
44
|
+
|
|
45
|
+
if (isOpen) {
|
|
46
|
+
document.addEventListener("keydown", this._onKey)
|
|
47
|
+
document.body.style.overflow = "hidden"
|
|
48
|
+
queueMicrotask(() => this.panelTarget?.focus())
|
|
49
|
+
this.dispatch("opened")
|
|
50
|
+
return
|
|
51
|
+
}
|
|
37
52
|
|
|
38
|
-
_hide() {
|
|
39
|
-
this.openValue = false
|
|
40
|
-
if (this.hasOverlayTarget) this.overlayTarget.hidden = true
|
|
41
|
-
if (this.hasPanelTarget) this.panelTarget.hidden = true
|
|
42
53
|
document.removeEventListener("keydown", this._onKey)
|
|
43
|
-
|
|
44
|
-
|
|
54
|
+
this._releaseScroll()
|
|
55
|
+
// Only return focus on a real close, not on the initial render.
|
|
56
|
+
if (wasOpen !== undefined) {
|
|
57
|
+
this.triggerTarget?.focus?.()
|
|
58
|
+
this.dispatch("closed")
|
|
59
|
+
}
|
|
45
60
|
}
|
|
46
61
|
|
|
47
62
|
_onKey(event) {
|
|
48
|
-
if (event.key === "Escape") this.
|
|
63
|
+
if (event.key === "Escape") this.close()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Turbo snapshots the page before navigating away. Without this the scroll
|
|
67
|
+
// lock is captured while still applied, and a back navigation restores a page
|
|
68
|
+
// that cannot be scrolled.
|
|
69
|
+
_onBeforeCache() {
|
|
70
|
+
this.openValue = false
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
_releaseScroll() {
|
|
74
|
+
document.body.style.overflow = ""
|
|
49
75
|
}
|
|
50
76
|
}
|
|
@@ -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
|
-
|
|
16
|
-
document.removeEventListener("keydown", this._onKey)
|
|
19
|
+
this._stopListening()
|
|
17
20
|
}
|
|
18
21
|
|
|
19
22
|
toggle(event) {
|
|
20
23
|
event?.preventDefault()
|
|
21
|
-
this.
|
|
24
|
+
this.openValue = !this.openValue
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
close() {
|
|
25
|
-
this.
|
|
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.
|
|
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")
|
|
43
|
-
if (event.key === "Escape")
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
15
|
-
document.removeEventListener("keydown", this._onKey)
|
|
19
|
+
this._stopListening()
|
|
16
20
|
}
|
|
17
21
|
|
|
18
22
|
toggle(event) {
|
|
19
23
|
event?.preventDefault()
|
|
20
|
-
this.
|
|
24
|
+
this.openValue = !this.openValue
|
|
21
25
|
}
|
|
22
26
|
|
|
23
27
|
close() {
|
|
24
|
-
this.
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
30
|
-
this.
|
|
31
|
-
document.
|
|
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) {
|
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus"
|
|
2
2
|
|
|
3
|
+
const ALLOWED_LINK_PROTOCOLS = new Set(["http:", "https:", "mailto:", "tel:"])
|
|
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
|
+
|
|
3
15
|
// senren--rich-text-editor-lite
|
|
4
16
|
// Local UI: tiny contenteditable toolbar synced to a hidden textarea.
|
|
5
17
|
export default class extends Controller {
|
|
@@ -15,7 +27,7 @@ export default class extends Controller {
|
|
|
15
27
|
this.sync()
|
|
16
28
|
this.rememberSelection()
|
|
17
29
|
this.updateToolbar()
|
|
18
|
-
this.debug("connect", this.snapshot())
|
|
30
|
+
this.debug("connect", () => (this.snapshot()))
|
|
19
31
|
}
|
|
20
32
|
|
|
21
33
|
keepSelection(event) {
|
|
@@ -31,7 +43,12 @@ export default class extends Controller {
|
|
|
31
43
|
if (!event.metaKey && !event.ctrlKey) return
|
|
32
44
|
|
|
33
45
|
event.preventDefault()
|
|
34
|
-
|
|
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")
|
|
35
52
|
}
|
|
36
53
|
|
|
37
54
|
format(event) {
|
|
@@ -39,11 +56,11 @@ export default class extends Controller {
|
|
|
39
56
|
const command = event.currentTarget.dataset.command
|
|
40
57
|
if (!command) return
|
|
41
58
|
|
|
42
|
-
this.debug("format:start", { command, before: this.snapshot() })
|
|
59
|
+
this.debug("format:start", () => ({ command, before: this.snapshot() }))
|
|
43
60
|
this.restoreSelection()
|
|
44
61
|
if (command === "createLink") {
|
|
45
62
|
const url = window.prompt("Paste a URL")
|
|
46
|
-
this.debug("createLink:prompt", { url, afterPrompt: this.snapshot() })
|
|
63
|
+
this.debug("createLink:prompt", () => ({ url, afterPrompt: this.snapshot() }))
|
|
47
64
|
if (!url) return
|
|
48
65
|
this.restoreSelection()
|
|
49
66
|
this.insertLink(url)
|
|
@@ -63,7 +80,7 @@ export default class extends Controller {
|
|
|
63
80
|
this.sync()
|
|
64
81
|
this.updateToolbar()
|
|
65
82
|
this.rememberSelection()
|
|
66
|
-
this.debug("format:done", { command, after: this.snapshot() })
|
|
83
|
+
this.debug("format:done", () => ({ command, after: this.snapshot() }))
|
|
67
84
|
}
|
|
68
85
|
|
|
69
86
|
sync() {
|
|
@@ -75,6 +92,96 @@ export default class extends Controller {
|
|
|
75
92
|
requestAnimationFrame(() => this.sync())
|
|
76
93
|
}
|
|
77
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
|
+
|
|
78
185
|
updateToolbar() {
|
|
79
186
|
this.buttonTargets.forEach((button) => {
|
|
80
187
|
const command = button.dataset.command
|
|
@@ -120,9 +227,9 @@ export default class extends Controller {
|
|
|
120
227
|
const range = selection.getRangeAt(0)
|
|
121
228
|
if (this.rangeInsideEditor(range)) {
|
|
122
229
|
this.savedRange = range.cloneRange()
|
|
123
|
-
this.debug("rememberSelection:saved", this.describeRange(this.savedRange))
|
|
230
|
+
this.debug("rememberSelection:saved", () => (this.describeRange(this.savedRange)))
|
|
124
231
|
} else {
|
|
125
|
-
this.debug("rememberSelection:outside", this.describeRange(range))
|
|
232
|
+
this.debug("rememberSelection:outside", () => (this.describeRange(range)))
|
|
126
233
|
}
|
|
127
234
|
}
|
|
128
235
|
|
|
@@ -132,17 +239,17 @@ export default class extends Controller {
|
|
|
132
239
|
const selection = window.getSelection()
|
|
133
240
|
selection.removeAllRanges()
|
|
134
241
|
selection.addRange(this.savedRange)
|
|
135
|
-
this.debug("restoreSelection:saved", this.describeRange(this.savedRange))
|
|
242
|
+
this.debug("restoreSelection:saved", () => (this.describeRange(this.savedRange)))
|
|
136
243
|
return
|
|
137
244
|
}
|
|
138
245
|
|
|
139
|
-
this.debug("restoreSelection:endFallback", this.snapshot())
|
|
246
|
+
this.debug("restoreSelection:endFallback", () => (this.snapshot()))
|
|
140
247
|
this.placeCaretAtEnd()
|
|
141
248
|
}
|
|
142
249
|
|
|
143
250
|
insertLink(rawUrl) {
|
|
144
251
|
const url = this.normalizeUrl(rawUrl)
|
|
145
|
-
this.debug("insertLink:start", { rawUrl, url, before: this.snapshot() })
|
|
252
|
+
this.debug("insertLink:start", () => ({ rawUrl, url, before: this.snapshot() }))
|
|
146
253
|
if (!url) return
|
|
147
254
|
|
|
148
255
|
const range = this.activeRange()
|
|
@@ -155,7 +262,7 @@ export default class extends Controller {
|
|
|
155
262
|
anchor.href = url
|
|
156
263
|
anchor.rel = "noopener noreferrer"
|
|
157
264
|
anchor.target = "_blank"
|
|
158
|
-
this.debug("insertLink:range", this.describeRange(range))
|
|
265
|
+
this.debug("insertLink:range", () => (this.describeRange(range)))
|
|
159
266
|
|
|
160
267
|
if (range.collapsed) {
|
|
161
268
|
anchor.textContent = url
|
|
@@ -170,12 +277,12 @@ export default class extends Controller {
|
|
|
170
277
|
const selection = window.getSelection()
|
|
171
278
|
selection.removeAllRanges()
|
|
172
279
|
selection.addRange(range)
|
|
173
|
-
this.debug("insertLink:done", { anchor: anchor.outerHTML, after: this.snapshot() })
|
|
280
|
+
this.debug("insertLink:done", () => ({ anchor: anchor.outerHTML, after: this.snapshot() }))
|
|
174
281
|
}
|
|
175
282
|
|
|
176
283
|
toggleList(tagName) {
|
|
177
284
|
const range = this.activeRange()
|
|
178
|
-
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() }))
|
|
179
286
|
if (!range) return
|
|
180
287
|
|
|
181
288
|
const activeList = this.closestElement(range.startContainer, "ul, ol")
|
|
@@ -185,12 +292,12 @@ export default class extends Controller {
|
|
|
185
292
|
} else {
|
|
186
293
|
this.convertList(activeList, tagName)
|
|
187
294
|
}
|
|
188
|
-
this.debug("toggleList:existingList", { tagName, after: this.snapshot() })
|
|
295
|
+
this.debug("toggleList:existingList", () => ({ tagName, after: this.snapshot() }))
|
|
189
296
|
return
|
|
190
297
|
}
|
|
191
298
|
|
|
192
299
|
const blocks = this.selectedBlocks(range)
|
|
193
|
-
this.debug("toggleList:blocks", { tagName, blocks: blocks.map((block) => block.outerHTML) })
|
|
300
|
+
this.debug("toggleList:blocks", () => ({ tagName, blocks: blocks.map((block) => block.outerHTML) }))
|
|
194
301
|
if (blocks.length === 0) return
|
|
195
302
|
|
|
196
303
|
const list = document.createElement(tagName)
|
|
@@ -202,15 +309,15 @@ export default class extends Controller {
|
|
|
202
309
|
})
|
|
203
310
|
|
|
204
311
|
blocks[0].before(list)
|
|
205
|
-
blocks.forEach((block) => block.remove())
|
|
312
|
+
blocks.forEach((block) => { block.remove() })
|
|
206
313
|
this.selectNodeContents(list)
|
|
207
|
-
this.debug("toggleList:done", { list: list.outerHTML, after: this.snapshot() })
|
|
314
|
+
this.debug("toggleList:done", () => ({ list: list.outerHTML, after: this.snapshot() }))
|
|
208
315
|
}
|
|
209
316
|
|
|
210
317
|
formatBlocks(tagName) {
|
|
211
318
|
const normalizedTagName = this.normalizedBlockTag(tagName)
|
|
212
319
|
const range = this.activeRange()
|
|
213
|
-
this.debug("formatBlocks:start", { tagName: normalizedTagName, range: this.describeRange(range) })
|
|
320
|
+
this.debug("formatBlocks:start", () => ({ tagName: normalizedTagName, range: this.describeRange(range) }))
|
|
214
321
|
if (!range) return
|
|
215
322
|
|
|
216
323
|
const blocks = this.selectedBlocks(range)
|
|
@@ -218,13 +325,13 @@ export default class extends Controller {
|
|
|
218
325
|
|
|
219
326
|
const replacements = blocks.map((block) => this.replaceBlock(block, normalizedTagName))
|
|
220
327
|
this.selectNodeContents(replacements[replacements.length - 1])
|
|
221
|
-
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) }))
|
|
222
329
|
}
|
|
223
330
|
|
|
224
331
|
alignBlocks(alignment) {
|
|
225
332
|
const normalizedAlignment = this.normalizedAlignment(alignment)
|
|
226
333
|
const range = this.activeRange()
|
|
227
|
-
this.debug("alignBlocks:start", { alignment: normalizedAlignment, range: this.describeRange(range) })
|
|
334
|
+
this.debug("alignBlocks:start", () => ({ alignment: normalizedAlignment, range: this.describeRange(range) }))
|
|
228
335
|
if (!range) return
|
|
229
336
|
|
|
230
337
|
const blocks = this.selectedBlocks(range)
|
|
@@ -237,7 +344,7 @@ export default class extends Controller {
|
|
|
237
344
|
})
|
|
238
345
|
|
|
239
346
|
if (blocks.length > 0) this.selectNodeContents(blocks[blocks.length - 1])
|
|
240
|
-
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) }))
|
|
241
348
|
}
|
|
242
349
|
|
|
243
350
|
unwrapList(list) {
|
|
@@ -277,11 +384,49 @@ export default class extends Controller {
|
|
|
277
384
|
}
|
|
278
385
|
}
|
|
279
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.
|
|
280
418
|
normalizeUrl(rawUrl) {
|
|
281
419
|
const url = String(rawUrl || "").trim()
|
|
282
|
-
if (url
|
|
283
|
-
if (
|
|
284
|
-
|
|
420
|
+
if (this.rejectedUrlShape(url)) return null
|
|
421
|
+
if (url.startsWith("#") || url.startsWith("/")) return url
|
|
422
|
+
|
|
423
|
+
const candidate = /^[a-z][a-z0-9+.-]*:/i.test(url) ? url : `https://${url}`
|
|
424
|
+
try {
|
|
425
|
+
const parsed = new URL(candidate, window.location.origin)
|
|
426
|
+
return ALLOWED_LINK_PROTOCOLS.has(parsed.protocol) ? parsed.href : null
|
|
427
|
+
} catch (_) {
|
|
428
|
+
return null
|
|
429
|
+
}
|
|
285
430
|
}
|
|
286
431
|
|
|
287
432
|
activeRange() {
|
|
@@ -289,19 +434,19 @@ export default class extends Controller {
|
|
|
289
434
|
if (selection && selection.rangeCount > 0) {
|
|
290
435
|
const range = selection.getRangeAt(0)
|
|
291
436
|
if (this.rangeInsideEditor(range)) {
|
|
292
|
-
this.debug("activeRange:selection", this.describeRange(range))
|
|
437
|
+
this.debug("activeRange:selection", () => (this.describeRange(range)))
|
|
293
438
|
return range
|
|
294
439
|
}
|
|
295
440
|
}
|
|
296
441
|
|
|
297
442
|
if (this.savedRange && this.rangeInsideEditor(this.savedRange)) {
|
|
298
|
-
this.debug("activeRange:saved", this.describeRange(this.savedRange))
|
|
443
|
+
this.debug("activeRange:saved", () => (this.describeRange(this.savedRange)))
|
|
299
444
|
return this.savedRange
|
|
300
445
|
}
|
|
301
446
|
|
|
302
447
|
this.placeCaretAtEnd()
|
|
303
448
|
const range = window.getSelection().getRangeAt(0)
|
|
304
|
-
this.debug("activeRange:endFallback", this.describeRange(range))
|
|
449
|
+
this.debug("activeRange:endFallback", () => (this.describeRange(range)))
|
|
305
450
|
return range
|
|
306
451
|
}
|
|
307
452
|
|
|
@@ -435,9 +580,11 @@ export default class extends Controller {
|
|
|
435
580
|
return `${node.tagName.toLowerCase()}${id}${classes}`
|
|
436
581
|
}
|
|
437
582
|
|
|
583
|
+
// `detail` may be a thunk so callers can defer building expensive payloads
|
|
584
|
+
// (full innerHTML/textContent snapshots) until debugging is actually on.
|
|
438
585
|
debug(message, detail = {}) {
|
|
439
586
|
if (!this.debugValue) return
|
|
440
587
|
|
|
441
|
-
console.debug("[senren rich text]", message, detail)
|
|
588
|
+
console.debug("[senren rich text]", message, typeof detail === "function" ? detail() : detail)
|
|
442
589
|
}
|
|
443
590
|
}
|