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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +249 -0
- data/bin/citrine +39 -0
- data/desktop/main.swift +44 -0
- data/lib/citrine/browser.rb +11 -0
- data/lib/citrine/canvas.rb +503 -0
- data/lib/citrine/component.rb +692 -0
- data/lib/citrine/debug.rb +109 -0
- data/lib/citrine/dev_server.rb +305 -0
- data/lib/citrine/dom.rb +292 -0
- data/lib/citrine/event.rb +31 -0
- data/lib/citrine/key_event.rb +39 -0
- data/lib/citrine/list_signal.rb +354 -0
- data/lib/citrine/node.rb +36 -0
- data/lib/citrine/num.rb +75 -0
- data/lib/citrine/packager.rb +129 -0
- data/lib/citrine/reactive.rb +40 -0
- data/lib/citrine/renderer.rb +828 -0
- data/lib/citrine/signal.rb +236 -0
- data/lib/citrine/sourcemap.rb +143 -0
- data/lib/citrine/string_renderer.rb +116 -0
- data/lib/citrine/style.rb +79 -0
- data/lib/citrine/theme.rb +99 -0
- data/lib/citrine/version.rb +5 -0
- data/lib/citrine.rb +92 -0
- data/lib/rubocop/cop/citrine/no_raw_ivar_assignment.rb +75 -0
- metadata +157 -0
data/lib/citrine/dom.rb
ADDED
|
@@ -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
|