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
|
@@ -0,0 +1,503 @@
|
|
|
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
|
+
# Canvas 2D 渲染器(Opal 环境)——可移植性演示后端。
|
|
10
|
+
#
|
|
11
|
+
# 与 DOM 渲染器共用 Renderer 基类的全部树管理 / Effect / 块级重建机制,
|
|
12
|
+
# 差异只在钩子实现:布局自管(direction/gap 的 stack/flow 语义)、
|
|
13
|
+
# 绘制原语、包围盒命中检测;任何变更后全量重绘(游戏式)。
|
|
14
|
+
# 文本输入用 window.prompt(演示级,避开 canvas 输入的 IME/光标深水区)。
|
|
15
|
+
class CanvasRenderer < Renderer
|
|
16
|
+
def initialize
|
|
17
|
+
super()
|
|
18
|
+
@hits = [] # [bbox, node],绘制时登记,点击时反查
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.mount_at(element_id, component)
|
|
22
|
+
Citrine.renderer = new
|
|
23
|
+
canvas = Citrine.renderer.document_element(element_id)
|
|
24
|
+
Citrine.mount(component, canvas)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def document_element(element_id)
|
|
28
|
+
Native(`window.document`).getElementById(element_id)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
# ── Renderer 钩子 ────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
def setup_root(root, element)
|
|
36
|
+
@root = root
|
|
37
|
+
@canvas = element
|
|
38
|
+
@ctx = element.getContext("2d")
|
|
39
|
+
root.dom = { x: 0, y: 0, w: @canvas[:width], h: @canvas[:height] }
|
|
40
|
+
element.addEventListener("click", ->(event) {
|
|
41
|
+
ev = Native(event)
|
|
42
|
+
handle_canvas_click(ev[:offsetX], ev[:offsetY])
|
|
43
|
+
})
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def create_dom(_node)
|
|
47
|
+
{} # 布局盒,redraw 时填充
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def attach(_node, _parent); end
|
|
51
|
+
|
|
52
|
+
# Canvas 没有"页面宿主"概念:portal 内容按画布根级布局/绘制
|
|
53
|
+
def resolve_portal_host(_target)
|
|
54
|
+
@root.dom
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def detach(_node); end
|
|
58
|
+
|
|
59
|
+
def apply_props(_node); end
|
|
60
|
+
|
|
61
|
+
# 响应式属性(G-2):Canvas 每次重绘都重新读 props,Proc 在绘制时求值
|
|
62
|
+
def style_of(node)
|
|
63
|
+
prop_value(node, node.props[:style]) || {}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def set_text(node, text)
|
|
67
|
+
node.text = text
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def setup_widget(node)
|
|
71
|
+
# T-B2:text_input 换成真实 <input> 覆盖层(中文输入法可用);
|
|
72
|
+
# 无 DOM 环境的宿主(Node 桩)回退 window.prompt 路径
|
|
73
|
+
setup_text_input_overlay(node) if node.type == :text_input && overlay_available?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# ── T-B2 spike:隐藏 DOM 测量 + 真实输入覆盖层 ──────────────
|
|
77
|
+
# 选型结论:隐藏 DOM 测量(0KB、白送字体回退与度量),不引入 Yoga。
|
|
78
|
+
# 环境探测带缓存:Node 桩等无 body 环境自动走旧路径,行为不回归。
|
|
79
|
+
|
|
80
|
+
def dom_measurement?
|
|
81
|
+
return @dom_measurement unless @dom_measurement.nil?
|
|
82
|
+
|
|
83
|
+
@dom_measurement = `typeof document !== 'undefined' && !!document.body`
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def overlay_available?
|
|
87
|
+
return @overlay_available unless @overlay_available.nil?
|
|
88
|
+
|
|
89
|
+
@overlay_available = `typeof document !== 'undefined' && !!document.body && !!document.createElement`
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def hidden_measurer
|
|
93
|
+
@hidden_measurer ||= begin
|
|
94
|
+
el = Native(`document.createElement("div")`)
|
|
95
|
+
style = el[:style]
|
|
96
|
+
style[:position] = "absolute"
|
|
97
|
+
style[:left] = "-99999px"
|
|
98
|
+
style[:top] = "0"
|
|
99
|
+
style[:visibility] = "hidden"
|
|
100
|
+
style[:whiteSpace] = "nowrap"
|
|
101
|
+
`document.body.appendChild(#{el.to_n})`
|
|
102
|
+
el
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# 按节点字体量测文本宽度(px)——度量与回退由浏览器负责
|
|
107
|
+
def dom_text_width(text, node)
|
|
108
|
+
el = hidden_measurer
|
|
109
|
+
el[:textContent] = text.to_s
|
|
110
|
+
el[:style][:font] = font_string(node)
|
|
111
|
+
el.getBoundingClientRect()[:width]
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# 真实 <input> 覆盖层:绝对定位到画布上方,value 信号双向绑定
|
|
115
|
+
def setup_text_input_overlay(node)
|
|
116
|
+
input = Native(`document.createElement("input")`)
|
|
117
|
+
input[:type] = "text"
|
|
118
|
+
value = node.props[:value]
|
|
119
|
+
input[:value] = value.get.to_s if value.is_a?(Signal)
|
|
120
|
+
input[:style][:position] = "absolute"
|
|
121
|
+
input[:style][:boxSizing] = "border-box"
|
|
122
|
+
input[:style][:border] = "1px solid #999"
|
|
123
|
+
input[:style][:font] = "15px sans-serif"
|
|
124
|
+
input[:style][:background] = "#fff"
|
|
125
|
+
overlay_root.appendChild(input)
|
|
126
|
+
if value.is_a?(Signal)
|
|
127
|
+
input.addEventListener("input", ->(_e) { value.set(input[:value]) })
|
|
128
|
+
node.owned_effects << Effect.create { input[:value] = value.get.to_s }
|
|
129
|
+
end
|
|
130
|
+
(@overlays ||= {})[node.object_id] = { node: node, input: input }
|
|
131
|
+
node
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# 覆盖层宿主:挂在 body 上的绝对定位层(节点坐标 = 画布视口偏移 + 布局盒)
|
|
135
|
+
def overlay_root
|
|
136
|
+
@overlay_root ||= begin
|
|
137
|
+
wrap = Native(`document.createElement("div")`)
|
|
138
|
+
wrap[:style][:cssText] = "position:absolute;left:0;top:0;width:0;height:0;overflow:visible"
|
|
139
|
+
`document.body.appendChild(#{wrap.to_n})`
|
|
140
|
+
wrap
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# 每次 redraw 后按布局盒重新定位覆盖层,并清掉已消失的输入框
|
|
145
|
+
def sync_overlays
|
|
146
|
+
return unless @overlays
|
|
147
|
+
|
|
148
|
+
canvas_rect = @canvas.getBoundingClientRect()
|
|
149
|
+
live = {}
|
|
150
|
+
collect_text_inputs = proc do |node|
|
|
151
|
+
live[node.object_id] = true if node.type == :text_input
|
|
152
|
+
node.children.each { |child| collect_text_inputs.call(child) }
|
|
153
|
+
end
|
|
154
|
+
collect_text_inputs.call(@root)
|
|
155
|
+
|
|
156
|
+
@overlays.each do |id, entry|
|
|
157
|
+
unless live.key?(id)
|
|
158
|
+
entry[:input].remove
|
|
159
|
+
@overlays.delete(id)
|
|
160
|
+
next
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
box = entry[:node].dom
|
|
164
|
+
style = entry[:input][:style]
|
|
165
|
+
style[:left] = "#{canvas_rect[:left] + box[:x]}px"
|
|
166
|
+
style[:top] = "#{canvas_rect[:top] + box[:y]}px"
|
|
167
|
+
style[:width] = "#{box[:w]}px"
|
|
168
|
+
style[:height] = "#{box[:h]}px"
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# 嵌套挂载期间不重绘,等最外层 settle(@parents 为空)后整体画一次
|
|
173
|
+
def finalize(_node)
|
|
174
|
+
redraw if @parents.empty?
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# 子组件 view 的信号重跑(S1-2)发生在父块渲染之外:重跑收尾时补一次整体重绘。
|
|
178
|
+
# 若重跑本身嵌在别的渲染过程里(@parents 非空),仍由最外层 finalize 统一重绘。
|
|
179
|
+
def rerun_component_view(child, parent)
|
|
180
|
+
super
|
|
181
|
+
ensure
|
|
182
|
+
redraw if @parents.empty?
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# ── 事件(命中检测 → 分发) ─────────────────────────────
|
|
186
|
+
|
|
187
|
+
def handle_canvas_click(x, y)
|
|
188
|
+
@hits.reverse_each do |box, node|
|
|
189
|
+
next unless x >= box[:x] && x <= box[:x] + box[:w] &&
|
|
190
|
+
y >= box[:y] && y <= box[:y] + box[:h]
|
|
191
|
+
|
|
192
|
+
dispatch(node)
|
|
193
|
+
break
|
|
194
|
+
end
|
|
195
|
+
redraw
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def dispatch(node)
|
|
199
|
+
case node.type
|
|
200
|
+
when :button
|
|
201
|
+
handler = node.props[:on_click]
|
|
202
|
+
node.owner.handle_event(handler) if handler
|
|
203
|
+
when :check_box
|
|
204
|
+
checked = node.props[:checked]
|
|
205
|
+
checked = checked.get if checked.is_a?(Signal)
|
|
206
|
+
handler = node.props[:on_change]
|
|
207
|
+
return unless handler
|
|
208
|
+
|
|
209
|
+
# 受控语义(S2-4):先写回信号再调处理器,处理器读到的是新勾选态
|
|
210
|
+
node.props[:checked].set(!checked) if node.props[:checked].is_a?(Signal)
|
|
211
|
+
node.owner.handle_event(handler, !checked)
|
|
212
|
+
when :text_input
|
|
213
|
+
dispatch_text_input(node)
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def dispatch_text_input(node)
|
|
218
|
+
# T-B2:有覆盖层时聚焦真实 <input>(IME 可用);否则回退 prompt 演示路径
|
|
219
|
+
overlay = @overlays && @overlays[node.object_id]
|
|
220
|
+
if overlay
|
|
221
|
+
overlay[:input].focus
|
|
222
|
+
return
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
text = prompt_value(prop_value(node, node.props[:placeholder]).to_s)
|
|
226
|
+
return if text.nil?
|
|
227
|
+
|
|
228
|
+
value = node.props[:value]
|
|
229
|
+
value.set(text) if value.is_a?(Signal)
|
|
230
|
+
handler = node.props[:on_enter]
|
|
231
|
+
node.owner.handle_event(handler) if handler
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def prompt_value(message)
|
|
235
|
+
return nil unless `typeof window.prompt === 'function'`
|
|
236
|
+
|
|
237
|
+
`window.prompt(#{message})`
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# ── 布局:size(后序测尺寸)→ place(前序定位) ──────────
|
|
241
|
+
|
|
242
|
+
def redraw
|
|
243
|
+
return unless @root && @ctx
|
|
244
|
+
|
|
245
|
+
@hits = []
|
|
246
|
+
# P1:本轮重绘的尺寸缓存。size_of 原本随 place 的前序遍历被逐层重复递归
|
|
247
|
+
# (复杂度 深度 × 节点数,叶子量测重复最狠)——每轮重绘只算一次、缓存命中即返回;
|
|
248
|
+
# ensure 里清空:样式 / props 变化后的下一轮重绘必须重新量测
|
|
249
|
+
@size_cache = {}
|
|
250
|
+
@ctx.clearRect(0, 0, @root.dom[:w], @root.dom[:h])
|
|
251
|
+
place(@root, 0, 0)
|
|
252
|
+
paint(@root)
|
|
253
|
+
sync_overlays
|
|
254
|
+
ensure
|
|
255
|
+
@size_cache = nil
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def box?(node)
|
|
259
|
+
# fragment / portal(S1-4 / S1-5):透明容器按容器参与布局,让多根/弹层照常展开
|
|
260
|
+
node.type == :root || node.type == :box || node.type == :fragment || node.type == :portal
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def box_axes(node)
|
|
264
|
+
style = style_of(node)
|
|
265
|
+
pad = px_num(node.type == :root ? (style[:padding] || 12) : (style[:padding] || 0))
|
|
266
|
+
gap = (gap_prop = prop_value(node, node.props[:gap])) ? gap_prop.to_i : 10
|
|
267
|
+
direction = if node.type == :root || prop_value(node, node.props[:direction]) == :column
|
|
268
|
+
:column
|
|
269
|
+
else
|
|
270
|
+
:row
|
|
271
|
+
end
|
|
272
|
+
[direction, pad, gap]
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def size_of(node)
|
|
276
|
+
return [@root.dom[:w], @root.dom[:h]] if node.type == :root
|
|
277
|
+
|
|
278
|
+
# P1:命中本轮缓存直接返回——首次计算时已把尺寸写进 node.dom,place/paint 只读
|
|
279
|
+
return @size_cache[node.object_id] if @size_cache&.key?(node.object_id)
|
|
280
|
+
|
|
281
|
+
if box?(node)
|
|
282
|
+
direction, pad, gap = box_axes(node)
|
|
283
|
+
sizes = node.children.map { |child| size_of(child) }
|
|
284
|
+
n = node.children.size
|
|
285
|
+
gaps = n > 1 ? gap * (n - 1) : 0
|
|
286
|
+
main = direction == :column ? sizes.sum(&:last) + gaps : sizes.sum(&:first) + gaps
|
|
287
|
+
cross = direction == :column ? (sizes.map(&:first).max || 0) : (sizes.map(&:last).max || 0)
|
|
288
|
+
w = (direction == :column ? cross : main) + 2 * pad
|
|
289
|
+
h = (direction == :column ? main : cross) + 2 * pad
|
|
290
|
+
else
|
|
291
|
+
w, h = leaf_size(node)
|
|
292
|
+
end
|
|
293
|
+
node.dom = node.dom.merge(w: w, h: h)
|
|
294
|
+
@size_cache[node.object_id] = [w, h] if @size_cache
|
|
295
|
+
[w, h]
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def leaf_size(node)
|
|
299
|
+
case node.type
|
|
300
|
+
when :text_input then [240, 36]
|
|
301
|
+
when :check_box then [18, 18]
|
|
302
|
+
else
|
|
303
|
+
text = display_text(node).to_s
|
|
304
|
+
# T-B2 spike(隐藏 DOM 测量):真机里按同一字体用隐藏元素量测宽度——
|
|
305
|
+
# 字体回退 / CJK 度量交给浏览器;Node 桩等无 body 环境回退 measureText。
|
|
306
|
+
width = dom_measurement? ? dom_text_width(text, node) : ctx_text_width(text, node)
|
|
307
|
+
width += node.type == :button ? 24 : 4
|
|
308
|
+
height = font_size(node) * (node.type == :button ? 1.9 : 1.6)
|
|
309
|
+
|
|
310
|
+
# T-B2:显式 style width 且文本超宽 → 贪心断行,多行堆叠
|
|
311
|
+
max_w = wrap_max_width(node)
|
|
312
|
+
if max_w && width > max_w
|
|
313
|
+
lines = wrapped_lines(text, node, max_w)
|
|
314
|
+
width = max_w
|
|
315
|
+
height = lines.size * font_size(node) * 1.5
|
|
316
|
+
end
|
|
317
|
+
[width, height]
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# 断行宽度上限:label 的显式 style width
|
|
322
|
+
def wrap_max_width(node)
|
|
323
|
+
w = style_of(node)[:width]
|
|
324
|
+
w ? px_num(w) : nil
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# 贪心断行:CJK 逐字、拉丁/数字按词、空白串整体。
|
|
328
|
+
# 返回各行文本;调用方需已设置 @ctx.font(量测用)。
|
|
329
|
+
def wrapped_lines(text, node, max_width)
|
|
330
|
+
@ctx.font = font_string(node)
|
|
331
|
+
lines = []
|
|
332
|
+
current = ""
|
|
333
|
+
wrap_units(text).each do |unit|
|
|
334
|
+
candidate = current + unit
|
|
335
|
+
if current.empty? || @ctx.measureText(candidate)[:width] <= max_width
|
|
336
|
+
current = candidate
|
|
337
|
+
else
|
|
338
|
+
lines << current
|
|
339
|
+
current = unit
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
lines << current unless current.empty?
|
|
343
|
+
lines
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# 分词:拉丁/数字词整体、空白串整体、其余逐字(CJK 逐字断行)
|
|
347
|
+
def wrap_units(text)
|
|
348
|
+
text.to_s.scan(/[A-Za-z0-9]+|\s+|[^A-Za-z0-9\s]/)
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def ctx_text_width(text, node)
|
|
352
|
+
@ctx.font = font_string(node)
|
|
353
|
+
@ctx.measureText(text)[:width]
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def place(node, x, y)
|
|
357
|
+
size_of(node)
|
|
358
|
+
node.dom = node.dom.merge(x: x, y: y)
|
|
359
|
+
return unless box?(node)
|
|
360
|
+
|
|
361
|
+
direction, pad, gap = box_axes(node)
|
|
362
|
+
cx = x + pad
|
|
363
|
+
cy = y + pad
|
|
364
|
+
node.children.each do |child|
|
|
365
|
+
place(child, cx, cy)
|
|
366
|
+
if direction == :column
|
|
367
|
+
cy += child.dom[:h] + gap
|
|
368
|
+
else
|
|
369
|
+
cx += child.dom[:w] + gap
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# ── 绘制 ───────────────────────────────────────────────
|
|
375
|
+
|
|
376
|
+
def paint(node)
|
|
377
|
+
case node.type
|
|
378
|
+
when :root
|
|
379
|
+
node.children.each { |child| paint(child) }
|
|
380
|
+
when :box
|
|
381
|
+
style = style_of(node)
|
|
382
|
+
bg = style[:background] || style[:background_color]
|
|
383
|
+
# 渐变等复杂值在 Canvas 上跳过(仅支持纯色),避免污染当前 fillStyle
|
|
384
|
+
if bg.is_a?(String) && bg.start_with?("#", "rgb")
|
|
385
|
+
@ctx.fillStyle = bg
|
|
386
|
+
@ctx.fillRect(node.dom[:x], node.dom[:y], node.dom[:w], node.dom[:h])
|
|
387
|
+
end
|
|
388
|
+
node.children.each { |child| paint(child) }
|
|
389
|
+
when :fragment, :portal
|
|
390
|
+
# 透明容器:只递归子根,自身不占绘制
|
|
391
|
+
node.children.each { |child| paint(child) }
|
|
392
|
+
when :label
|
|
393
|
+
paint_label(node)
|
|
394
|
+
when :button
|
|
395
|
+
paint_button(node)
|
|
396
|
+
when :text_input
|
|
397
|
+
paint_text_input(node)
|
|
398
|
+
when :check_box
|
|
399
|
+
paint_check_box(node)
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def paint_label(node)
|
|
404
|
+
style = style_of(node)
|
|
405
|
+
color = style[:color] || "#222"
|
|
406
|
+
@ctx.font = font_string(node)
|
|
407
|
+
@ctx.fillStyle = color
|
|
408
|
+
|
|
409
|
+
# T-B2:多行文本逐行绘制(换行条件与 leaf_size 一致)
|
|
410
|
+
max_w = wrap_max_width(node)
|
|
411
|
+
lines = max_w ? wrapped_lines(display_text(node).to_s, node, max_w) : [display_text(node).to_s]
|
|
412
|
+
line_h = font_size(node) * 1.5
|
|
413
|
+
lines.each_with_index do |line, i|
|
|
414
|
+
@ctx.fillText(line, node.dom[:x] + 2, node.dom[:y] + font_size(node) * 1.15 + i * line_h)
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
return unless lines.size == 1 && style[:text_decoration] == "line-through"
|
|
418
|
+
|
|
419
|
+
y_mid = node.dom[:y] + font_size(node) * 0.75
|
|
420
|
+
@ctx.strokeStyle = color
|
|
421
|
+
@ctx.moveTo(node.dom[:x] + 2, y_mid)
|
|
422
|
+
@ctx.lineTo(node.dom[:x] + 2 + @ctx.measureText(lines.first)[:width], y_mid)
|
|
423
|
+
@ctx.stroke
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def paint_button(node)
|
|
427
|
+
box = node.dom
|
|
428
|
+
text = display_text(node)
|
|
429
|
+
@ctx.fillStyle = "#f3f3f3"
|
|
430
|
+
@ctx.fillRect(box[:x], box[:y], box[:w], box[:h])
|
|
431
|
+
@ctx.strokeStyle = "#999"
|
|
432
|
+
@ctx.strokeRect(box[:x], box[:y], box[:w], box[:h])
|
|
433
|
+
@ctx.font = font_string(node)
|
|
434
|
+
@ctx.fillStyle = "#222"
|
|
435
|
+
@ctx.fillText(text, box[:x] + (box[:w] - @ctx.measureText(text)[:width]) / 2,
|
|
436
|
+
box[:y] + font_size(node) * 1.25)
|
|
437
|
+
@hits << [box, node]
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def paint_text_input(node)
|
|
441
|
+
# 真实 <input> 覆盖层已就位:不再绘制占位框(避免视觉重复)
|
|
442
|
+
return if @overlays && @overlays.key?(node.object_id)
|
|
443
|
+
|
|
444
|
+
box = node.dom
|
|
445
|
+
value = node.props[:value]
|
|
446
|
+
value = value.get if value.is_a?(Signal)
|
|
447
|
+
value = value.to_s
|
|
448
|
+
@ctx.fillStyle = "#fff"
|
|
449
|
+
@ctx.fillRect(box[:x], box[:y], box[:w], box[:h])
|
|
450
|
+
@ctx.strokeStyle = "#aaa"
|
|
451
|
+
@ctx.strokeRect(box[:x], box[:y], box[:w], box[:h])
|
|
452
|
+
@ctx.font = "15px sans-serif"
|
|
453
|
+
if value.empty?
|
|
454
|
+
@ctx.fillStyle = "#999"
|
|
455
|
+
@ctx.fillText(prop_value(node, node.props[:placeholder]).to_s, box[:x] + 8, box[:y] + 23)
|
|
456
|
+
else
|
|
457
|
+
@ctx.fillStyle = "#222"
|
|
458
|
+
@ctx.fillText(value[0, 20], box[:x] + 8, box[:y] + 23)
|
|
459
|
+
end
|
|
460
|
+
@hits << [box, node]
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def paint_check_box(node)
|
|
464
|
+
box = node.dom
|
|
465
|
+
@ctx.fillStyle = "#fff"
|
|
466
|
+
@ctx.fillRect(box[:x], box[:y], box[:w], box[:h])
|
|
467
|
+
@ctx.strokeStyle = "#666"
|
|
468
|
+
@ctx.strokeRect(box[:x], box[:y], box[:w], box[:h])
|
|
469
|
+
@hits << [box, node]
|
|
470
|
+
checked = node.props[:checked]
|
|
471
|
+
checked = checked.get if checked.is_a?(Signal)
|
|
472
|
+
return unless checked
|
|
473
|
+
|
|
474
|
+
@ctx.fillStyle = "#333"
|
|
475
|
+
@ctx.font = "14px sans-serif"
|
|
476
|
+
@ctx.fillText("✓", box[:x] + 3, box[:y] + 14)
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
# ── 工具 ───────────────────────────────────────────────
|
|
480
|
+
|
|
481
|
+
def display_text(node)
|
|
482
|
+
node.text.to_s
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
def font_size(node)
|
|
486
|
+
px_num(style_of(node)[:font_size] || 16)
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
def font_string(node)
|
|
490
|
+
weight = style_of(node)[:font_weight]
|
|
491
|
+
size = font_size(node)
|
|
492
|
+
if weight && weight.to_s != "400"
|
|
493
|
+
"#{weight} #{size}px sans-serif"
|
|
494
|
+
else
|
|
495
|
+
"#{size}px sans-serif"
|
|
496
|
+
end
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
def px_num(value)
|
|
500
|
+
value.to_s.sub("px", "").to_f
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
end
|