citrine 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.
@@ -0,0 +1,292 @@
1
+ # backtick_javascript: true
2
+ # frozen_string_literal: true
3
+
4
+ require "native"
5
+ require "citrine"
6
+ require "citrine/renderer"
7
+
8
+ module Citrine
9
+ # Web DOM 渲染器(Opal 环境):实现 Renderer 的平台钩子。
10
+ class DomRenderer < Renderer
11
+ def initialize
12
+ @document = Native(`window.document`)
13
+ super()
14
+ end
15
+
16
+ def self.mount_at(element_id, component)
17
+ # F2:一页多根时复用同一渲染器实例("最后挂载者胜出"会清空先挂载的组件)
18
+ unless Citrine.renderer.is_a?(DomRenderer)
19
+ Citrine.renderer = new
20
+ end
21
+ element = Citrine.renderer.document_element(element_id)
22
+ if element.nil?
23
+ raise %(Citrine.mount_at:找不到 id 为 "#{element_id}" 的元素(确认 HTML 里有 <div id="#{element_id}">…</div> 再挂载))
24
+ end
25
+
26
+ Citrine.mount(component, element)
27
+ end
28
+
29
+ def document_element(element_id)
30
+ @document.getElementById(element_id)
31
+ end
32
+
33
+ private
34
+
35
+ def setup_root(root, element)
36
+ root.dom = element.is_a?(Native::Object) ? element : Native(element)
37
+ end
38
+
39
+ def create_dom(node)
40
+ @document.createElement(TAGS[node.type] || node.type.to_s)
41
+ end
42
+
43
+ def attach(node, parent)
44
+ parent.dom.appendChild(node.dom)
45
+ end
46
+
47
+ # S1-2:view 重跑把节点 append 到末尾;按 anchor 挪回原渲染位(保持兄弟次序)。
48
+ # anchor 已在基类保证非 nil;fragment 无自身 DOM,取它第一个子根作插入锚点。
49
+ def attach_before(node, parent, anchor)
50
+ ref = anchor.type == :fragment ? anchor.children.first&.dom : anchor.dom
51
+ parent.dom.insertBefore(node.dom, ref) if ref
52
+ end
53
+
54
+ def detach(node)
55
+ restore_focus(node) # S2-6:卸载带 autofocus 的节点时恢复上一焦点
56
+ parent_dom = node.dom[:parentElement]
57
+ parent_dom.removeChild(node.dom) if parent_dom
58
+ end
59
+
60
+ # 幂等:响应式属性重跑时会再次调用(见 Renderer#mount)
61
+ def apply_props(node)
62
+ el = node.dom
63
+ if node.props.key?(:css_class)
64
+ css_class = prop_value(node, node.props[:css_class])
65
+ # A7:数组形式([:card, :active])空格 join 后与 SSR class 属性同口径
66
+ el[:className] = css_class.is_a?(Array) ? css_class.join(" ") : css_class.to_s
67
+ end
68
+ el[:placeholder] = prop_value(node, node.props[:placeholder]).to_s if node.props.key?(:placeholder)
69
+
70
+ style = resolve_style(node)
71
+ # 响应式 style 换掉整份内联样式:先清掉本次不再出现的旧键(错误态高亮必须能消失)
72
+ track_style_keys(node, style.keys).each { |key| el[:style][Style.camel(key)] = "" }
73
+ style.each { |key, value| el[:style][Style.camel(key)] = value.to_s }
74
+
75
+ # S2-2:未消费属性原样透传(id / disabled / aria-* / data-* / title…)。
76
+ # 值可以是响应式的——值翻 false/nil 时属性要能消失,所以记下应用过的键、先清旧键。
77
+ applied = passthrough_props(node)
78
+ applied_names = applied.map { |(name, _)| name }
79
+ (node.applied_attrs || []).each do |stale|
80
+ el.removeAttribute(stale) unless applied_names.include?(stale)
81
+ end
82
+ node.applied_attrs = applied_names
83
+ applied.each { |(name, value)| el.setAttribute(name, value) }
84
+
85
+ ensure_events(node) # 复用路径上新增的处理器在此补齐(幂等)
86
+ end
87
+
88
+ # 事件监听**按需绑定**:props 里真的有处理器时才 addEventListener,
89
+ # 并记在 node.bound_listeners 上(挂过就不再挂)。事件发生时从 node.props 现取处理器,
90
+ # 所以复用时不需要重绑——新增的处理器由 apply_props → ensure_events 补上。
91
+ # 以前是每个元素无条件挂 4 个监听:多数节点(box/label)根本没有处理器,白挂闭包。
92
+ def bind_events(node)
93
+ ensure_events(node)
94
+ end
95
+
96
+ # S2-3:事件面——prop 名 → DOM 事件名(冻结常量,一次定义全实例共享)。
97
+ # 处理器收到平台无关视图(键盘是 KeyEvent,其余是 Citrine::Event),原生细节走 #raw。
98
+ EVENT_DEFS = {
99
+ on_click: :click, on_focus: :focus, on_blur: :blur,
100
+ on_key: :keydown, on_key_up: :keyup,
101
+ on_dblclick: :dblclick, on_contextmenu: :contextmenu,
102
+ on_mouse_enter: :mouseenter, on_mouse_leave: :mouseleave,
103
+ on_mouse_down: :mousedown, on_mouse_up: :mouseup,
104
+ on_wheel: :wheel, on_scroll: :scroll,
105
+ on_submit: :submit, on_paste: :paste,
106
+ on_touch_start: :touchstart, on_touch_move: :touchmove, on_touch_end: :touchend,
107
+ on_pointer_down: :pointerdown, on_pointer_move: :pointermove, on_pointer_up: :pointerup
108
+ }.freeze
109
+
110
+ def ensure_events(node)
111
+ owner = node.owner
112
+
113
+ EVENT_DEFS.each do |prop, event_name|
114
+ ensure_event(node, event_name, prop, event_name.to_s) do |event|
115
+ handler = node.props[prop]
116
+ next unless handler
117
+
118
+ view = event_view(event_name, event)
119
+ # 键盘处理器支持键表形式(on_key: { "Escape" => :x }),走 handle_key
120
+ if prop == :on_key || prop == :on_key_up
121
+ owner.handle_key(handler, view)
122
+ else
123
+ owner.handle_event(handler, view)
124
+ end
125
+ end
126
+ end
127
+
128
+ # text_input 的 on_enter / check_box 的 on_change 走同一套按需绑定
129
+ ensure_event(node, :enter, :on_enter, "keydown") do |event|
130
+ ev = Native(event)
131
+ handler = node.props[:on_enter]
132
+ # IME 组合(S2-4):选词确认的 Enter 不触发 on_enter
133
+ next unless handler && ev[:key] == "Enter" && ev[:isComposing] != true
134
+
135
+ owner.handle_event(handler, ev)
136
+ end
137
+ ensure_event(node, :change, :on_change, "change") do |_event|
138
+ handler = node.props[:on_change]
139
+ next unless handler
140
+
141
+ # 受控语义(S2-4):checked 传 Signal 时真正双向绑定——先写回再派发,
142
+ # 处理器读到的是新勾选态(与 text_input 的 value 对称)
143
+ checked = node.props[:checked]
144
+ checked.set(node.dom[:checked]) if checked.is_a?(Signal)
145
+ owner.handle_event(handler, node.dom[:checked])
146
+ end
147
+ end
148
+
149
+ # 原生事件 → 平台无关视图:键盘给 KeyEvent,其余给 Citrine::Event
150
+ def event_view(event_name, event)
151
+ return key_event(event) if event_name == :keydown || event_name == :keyup
152
+
153
+ ev = Native(event)
154
+ Event.new(event_name.to_s, raw: ev,
155
+ prevent_default: -> { ev.preventDefault },
156
+ stop_propagation: -> { ev.stopPropagation })
157
+ end
158
+
159
+ def ensure_event(node, tag, prop, event_name, &listener)
160
+ bound = (node.bound_listeners ||= {})
161
+ return if bound[tag] || !node.props.key?(prop)
162
+
163
+ bound[tag] = true
164
+ node.dom.addEventListener(event_name, listener)
165
+ end
166
+
167
+ # 全局键盘(G-9):window 级 keydown,绑定组件生命周期(卸载时由 unmount_component 解绑)。
168
+ # S2-3:带 scope: :focused 的处理器只在焦点落在组件子树内时才分发。
169
+ def register_window_key(component, handler)
170
+ win = Native(`window`)
171
+ scoped = handler.is_a?(Component::WindowKey)
172
+ body = scoped ? handler.handler : handler
173
+ # 不要在 lambda 里用 return/next 之外的提前返回写法:lambda 的 return
174
+ # 走 throw 机制,逃逸到原生 addEventListener 后变成未捕获异常
175
+ listener = ->(event) {
176
+ unless scoped && !focused_in?(component)
177
+ component.handle_key(body, key_event(event))
178
+ end
179
+ }
180
+ win.addEventListener("keydown", listener)
181
+ @window_keys ||= {}
182
+ (@window_keys[component] ||= []) << listener
183
+ end
184
+
185
+ # 焦点作用域判定:activeElement 落在组件渲染的子树内(不在 → 包括焦点
186
+ # 在页面上别处或没有焦点元素的情况,都不分发)。
187
+ # 注意走 Native 派发而不是 backtick 插值——node.dom 是包装对象,插值会泄漏包装器。
188
+ def focused_in?(component)
189
+ root = component.respond_to?(:root) ? component.root : nil
190
+ return false unless root && root.dom
191
+
192
+ active = @document[:activeElement]
193
+ return false if active.nil?
194
+
195
+ root.dom.contains(active)
196
+ end
197
+
198
+ def unregister_window_keys(component)
199
+ listeners = @window_keys && @window_keys.delete(component)
200
+ return unless listeners
201
+
202
+ win = Native(`window`)
203
+ listeners.each { |listener| win.removeEventListener("keydown", listener) }
204
+ end
205
+
206
+ # 原生事件 → Citrine::KeyEvent(平台无关视图;需要的原生细节走 #raw)
207
+ def key_event(event)
208
+ ev = Native(event)
209
+ KeyEvent.new(ev[:key],
210
+ shift: ev[:shiftKey] == true, meta: ev[:metaKey] == true,
211
+ ctrl: ev[:ctrlKey] == true, alt: ev[:altKey] == true,
212
+ raw: ev, prevent_default: -> { ev.preventDefault })
213
+ end
214
+
215
+ # S1-5:portal 宿主解析——默认 body;显式 target 是选择器字符串,
216
+ # 解析不到时抛错(不静默回退,否则弹层会挂错地方)
217
+ def resolve_portal_host(target)
218
+ return @document[:body] if target.nil? || target == ""
219
+
220
+ host = @document.querySelector(target.to_s)
221
+ raise "Citrine.portal:找不到宿主元素 #{target.inspect}" if host.nil?
222
+
223
+ host
224
+ end
225
+
226
+ def set_text(node, text)
227
+ # 透明容器没有自己的文本位(借的是父容器的 DOM,写它会砸掉兄弟内容)
228
+ return if node.type == :fragment
229
+
230
+ node.dom[:textContent] = text.to_s
231
+ # 镜像进节点的文本槽(与 Canvas/SSR 同口径):Renderer#run_block 靠它
232
+ # 判定"上一轮写过文本",本轮没有内容时显式清空,防旧 textContent 残留
233
+ node.text = text
234
+ end
235
+
236
+ def finalize(node)
237
+ return if node.type == :root || node.type == :fragment
238
+
239
+ setup_autofocus(node) # S2-6:挂载收尾时聚焦(finalize 只在挂载路径跑一次)
240
+ end
241
+
242
+ # S2-6:autofocus 原语——挂载后把焦点移到该元素,并记录挂载前的活动元素;
243
+ # 卸载时(detach)恢复焦点到它。tabindex / aria_* 经属性透传(S2-2)直达 DOM。
244
+ # 同样走 Native 派发(node.dom 是包装对象)
245
+ def setup_autofocus(node)
246
+ return unless node.props[:autofocus]
247
+
248
+ node.focus_restore_target = @document[:activeElement]
249
+ node.dom.focus
250
+ end
251
+
252
+ def restore_focus(node)
253
+ return unless node.props[:autofocus] && (target = node.focus_restore_target)
254
+
255
+ target.focus
256
+ end
257
+
258
+ def setup_widget(node)
259
+ case node.type
260
+ when :text_input then setup_text_input(node)
261
+ when :check_box then setup_check_box(node)
262
+ end
263
+ end
264
+
265
+ def setup_text_input(node)
266
+ el = node.dom
267
+ el[:type] = node.props[:type] || "text"
268
+ value = node.props[:value]
269
+ if value.is_a?(Signal)
270
+ node.owned_effects << Effect.create { el[:value] = value.get.to_s }
271
+ el.addEventListener("input", ->(_event) { value.set(el[:value]) })
272
+ elsif value.is_a?(String)
273
+ # F20:字面量初值也要落到 DOM,保持与 SSR 输出一致
274
+ el[:value] = value
275
+ end
276
+ # on_enter 不在这里绑:与其它处理器一样走 ensure_events(按需 + 复用时补齐)
277
+ end
278
+
279
+ def setup_check_box(node)
280
+ el = node.dom
281
+ el[:type] = "checkbox"
282
+ checked = node.props[:checked]
283
+ if checked.is_a?(Signal)
284
+ # F19:Signal 驱动的勾选态要读信号并保持响应,而不是把对象当 truthy
285
+ node.owned_effects << Effect.create { el[:checked] = checked.get ? true : false }
286
+ else
287
+ el[:checked] = checked ? true : false
288
+ end
289
+ # on_change 同样交给 ensure_events
290
+ end
291
+ end
292
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Citrine
4
+ # 指针/表单等事件(平台无关视图,S2-3):DOM / Canvas 捕获原生事件后归一成它。
5
+ # 与 KeyEvent 同理——组件代码不混入平台原生对象,CRuby 也能单测;
6
+ # 需要原生细节时走 #raw(DOM 下是 Native 包装)。
7
+ class Event
8
+ attr_reader :type, :raw
9
+
10
+ def initialize(type, raw: nil, prevent_default: nil, stop_propagation: nil)
11
+ @type = type.to_s
12
+ @raw = raw
13
+ @prevent_default = prevent_default
14
+ @stop_propagation = stop_propagation
15
+ end
16
+
17
+ # 阻止默认行为(DOM:preventDefault;无平台回调时静默忽略)
18
+ def prevent_default
19
+ @prevent_default&.call
20
+ self
21
+ end
22
+
23
+ # 阻止事件继续传播:外层元素 / 祖先容器上的处理器不再收到这次事件
24
+ def stop_propagation
25
+ @stop_propagation&.call
26
+ self
27
+ end
28
+
29
+ def inspect = "#<Citrine::Event #{@type}>"
30
+ end
31
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Citrine
4
+ # 键盘事件(平台无关视图):DOM / Canvas 捕获原生事件后归一成它。
5
+ #
6
+ # 为什么要包装:键盘优先的应用(电子表格、编辑器)要判断"按了哪个键 + 有无修饰键 +
7
+ # 是否阻止默认行为",直接读 JS 事件会把平台细节漏进组件代码,也没法在 CRuby 里单测。
8
+ # 需要原生事件时用 #raw(DOM 下是 Native 包装)。
9
+ class KeyEvent
10
+ attr_reader :key, :raw
11
+
12
+ def initialize(key, shift: false, meta: false, ctrl: false, alt: false,
13
+ raw: nil, prevent_default: nil)
14
+ @key = key.to_s
15
+ @shift = shift
16
+ @meta = meta
17
+ @ctrl = ctrl
18
+ @alt = alt
19
+ @raw = raw
20
+ @prevent_default = prevent_default
21
+ end
22
+
23
+ def shift? = @shift
24
+ def meta? = @meta
25
+ def ctrl? = @ctrl
26
+ def alt? = @alt
27
+ # ⌘ / Ctrl 等价判断:应用里"保存/撤销"这类快捷键两边都要认
28
+ def command? = @meta || @ctrl
29
+
30
+ # 阻止默认行为(DOM:preventDefault;无平台回调时静默忽略)
31
+ def prevent_default
32
+ @prevent_default&.call
33
+ self
34
+ end
35
+
36
+ def to_s = @key
37
+ def inspect = "#<Citrine::KeyEvent #{@key}#{@meta ? ' meta' : ''}#{@ctrl ? ' ctrl' : ''}#{@shift ? ' shift' : ''}>"
38
+ end
39
+ end