@jiujue/react-canvas-fiber 2.0.4 → 2.0.6

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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # react-canvas-fiber
2
2
 
3
- [中文](./README.zh.md.md) | English
3
+ [中文](./README.zh.md) | English
4
4
 
5
5
  A Canvas custom renderer (2D) based on `react-reconciler`, integrated with Yoga Flexbox layout.
6
6
 
@@ -19,7 +19,7 @@ Inside this monorepo, it is referenced via pnpm workspace (see the demo).
19
19
  ## Usage
20
20
 
21
21
  ```tsx
22
- import { Canvas, Rect, Text, View } from '@jiujue/react-canvas-fiber'
22
+ import { Canvas, Image, Rect, Text, View } from '@jiujue/react-canvas-fiber'
23
23
 
24
24
  export function Example() {
25
25
  return (
@@ -27,17 +27,99 @@ export function Example() {
27
27
  <View style={{ width: 600, height: 400, padding: 16, flexDirection: 'column', gap: 12 }}>
28
28
  <Text text="Hello" style={{ fontSize: 24, fontWeight: 700 }} color="#e6edf7" />
29
29
  <Rect style={{ width: 180, height: 44 }} borderRadius={10} fill="#2b6cff" />
30
+ <Image
31
+ src="https://gw.alipayobjects.com/zos/antfincdn/LlvErxo8H9/photo-1503149779833-1de50ebe5f8a.webp"
32
+ style={{ width: 100, height: 100 }}
33
+ borderRadius={12}
34
+ objectFit="cover"
35
+ />
30
36
  </View>
31
37
  </Canvas>
32
38
  )
33
39
  }
34
40
  ```
35
41
 
36
- ## Supported Nodes
42
+ ## Components
37
43
 
38
- - `View`: layout container, supports `background/borderRadius`
39
- - `Rect`: rounded rectangle (fill/stroke/lineWidth/borderRadius)
40
- - `Text`: text (`text/color` + a subset of font styles)
44
+ ### `Canvas`
45
+
46
+ Bridge component that owns a DOM `<canvas>`, creates the custom renderer root, and forwards pointer/wheel events.
47
+
48
+ Common props:
49
+
50
+ - `width: number` / `height: number`: logical size (CSS pixels)
51
+ - `dpr?: number` (default `1`): device pixel ratio; the backing canvas uses `width * dpr`, `height * dpr`
52
+ - `clearColor?: string`: clears the whole canvas each frame
53
+ - `fontFamily?: string`, `fontSize?: number`, `fontWeight?: number | string`, `lineHeight?: number`: default text style (used when `Text`/ancestors don't provide them)
54
+ - `style?: CSSProperties`: applied to the DOM `<canvas>` (logical size, display, etc.)
55
+ - `children?: ReactNode`: scene tree
56
+
57
+ ### `View`
58
+
59
+ Layout container (Yoga) + optional background/border + optional scrolling.
60
+
61
+ Common props:
62
+
63
+ - `style?: YogaStyle`: layout box; see Layout section below
64
+ - `background?: string`: background fill color
65
+ - `border?: string`: CSS-like border. Supported forms:
66
+ - `"<number>px solid <color>"` (e.g. `1px solid rgba(255,255,255,0.2)`)
67
+ - `"<number> <color>"` (e.g. `2 #fff`)
68
+ - `"<color>"` (means `1px`, e.g. `#fff`)
69
+ - `borderRadius?: number`
70
+ - `scrollX?: boolean`, `scrollY?: boolean`: enable scrolling + clipping
71
+ - `scrollbarX?: boolean`, `scrollbarY?: boolean`: show scrollbars (default `true` when scrolling is enabled)
72
+ - `scrollbarWidth?: number` (default `10`)
73
+ - `scrollbarInset?: number` (default `6`)
74
+ - `scrollbarTrackColor?: string` (default `rgba(255,255,255,0.12)`)
75
+ - `scrollbarThumbColor?: string` (default `rgba(255,255,255,0.35)`)
76
+ - `onScrollX?: (scrollLeft: number) => void`
77
+ - `onScroll?: (scrollTop: number) => void`
78
+
79
+ ### `Rect`
80
+
81
+ Rounded rectangle primitive (can also contain children).
82
+
83
+ Common props:
84
+
85
+ - `style?: YogaStyle`
86
+ - `fill?: string` (default `#ffffff`)
87
+ - `stroke?: string`
88
+ - `lineWidth?: number` (default `1`)
89
+ - `borderRadius?: number`
90
+
91
+ ### `Text`
92
+
93
+ Text primitive. Use `text`, do not pass children.
94
+
95
+ Common props:
96
+
97
+ - `text: string` (required). `\n` is supported for manual line breaks.
98
+ - `style?: YogaStyle`: supports `fontSize/fontFamily/fontWeight/lineHeight` (also inherited from ancestors / `Canvas` defaults)
99
+ - `color?: string` (default `#ffffff`)
100
+ - `maxWidth?: number`: used by layout measurement to clamp measured width (does not auto-wrap during drawing)
101
+
102
+ ### `Image`
103
+
104
+ Image primitive. Use `src`, do not pass children.
105
+
106
+ Common props:
107
+
108
+ - `src: string` (required)
109
+ - `style?: YogaStyle`
110
+ - `objectFit?: 'cover' | 'contain' | 'fill'` (default `contain`)
111
+ - `borderRadius?: number`
112
+
113
+ ## Events
114
+
115
+ `View` / `Rect` / `Text` / `Image` support pointer events:
116
+
117
+ - Hit test respects `pointerEvents?: 'auto' | 'none'` (`none` makes the node transparent to hit testing).
118
+ - Bubble + capture events: `onPointerDown/Move/Up/Cancel`, `onClick`, plus `*Capture` variants.
119
+ - Hover events: `onPointerEnter`, `onPointerLeave` (no capture/bubble).
120
+ - `click` fires on `pointerup` when the down/up target is the same node and `button === 0`.
121
+
122
+ If an event handler calls `event.preventDefault()`, the DOM event will be `preventDefault()`'d on the underlying `<canvas>` when possible.
41
123
 
42
124
  ## Layout (Yoga Subset)
43
125
 
@@ -47,6 +129,7 @@ Provided mainly via `style`:
47
129
  - Flex: `flexDirection/justifyContent/alignItems/flexGrow/flexShrink/flexBasis/flexWrap/gap`
48
130
  - Spacing: `padding*`, `margin*`
49
131
  - Positioning: `position/top/right/bottom/left`
132
+ - Text style (used by `Text`): `fontSize/fontFamily/fontWeight/lineHeight`
50
133
 
51
134
  ## Implementation Notes
52
135
 
package/README.zh.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # react-canvas-fiber
2
2
 
3
- 中文 | [English](./README.en.md)
3
+ 中文 | [English](./README.md)
4
4
 
5
5
  基于 `react-reconciler` 的 Canvas 自定义渲染器(2D),并集成 Yoga Flexbox 布局。
6
6
 
@@ -19,7 +19,7 @@ pnpm add @jiujue/react-canvas-fiber
19
19
  ## 使用
20
20
 
21
21
  ```tsx
22
- import { Canvas, Rect, Text, View } from '@jiujue/react-canvas-fiber'
22
+ import { Canvas, Image, Rect, Text, View } from '@jiujue/react-canvas-fiber'
23
23
 
24
24
  export function Example() {
25
25
  return (
@@ -27,17 +27,99 @@ export function Example() {
27
27
  <View style={{ width: 600, height: 400, padding: 16, flexDirection: 'column', gap: 12 }}>
28
28
  <Text text="Hello" style={{ fontSize: 24, fontWeight: 700 }} color="#e6edf7" />
29
29
  <Rect style={{ width: 180, height: 44 }} borderRadius={10} fill="#2b6cff" />
30
+ <Image
31
+ src="https://gw.alipayobjects.com/zos/antfincdn/LlvErxo8H9/photo-1503149779833-1de50ebe5f8a.webp"
32
+ style={{ width: 100, height: 100 }}
33
+ borderRadius={12}
34
+ objectFit="cover"
35
+ />
30
36
  </View>
31
37
  </Canvas>
32
38
  )
33
39
  }
34
40
  ```
35
41
 
36
- ## 已支持的节点
42
+ ## 组件与属性
37
43
 
38
- - `View`:布局容器,可设置 `background/borderRadius`
39
- - `Rect`:圆角矩形(fill/stroke/lineWidth/borderRadius)
40
- - `Text`:文本(text/color + font style 子集)
44
+ ### `Canvas`
45
+
46
+ 桥接组件:负责创建/管理 DOM `<canvas>`、初始化自定义 renderer root,并转发 pointer/wheel 事件。
47
+
48
+ 常用 props:
49
+
50
+ - `width: number` / `height: number`:逻辑尺寸(CSS 像素)
51
+ - `dpr?: number`(默认 `1`):设备像素比;真实像素尺寸为 `width * dpr`、`height * dpr`
52
+ - `clearColor?: string`:每帧清屏颜色
53
+ - `fontFamily?: string`、`fontSize?: number`、`fontWeight?: number | string`、`lineHeight?: number`:文本默认样式(当 `Text`/祖先未提供时生效)
54
+ - `style?: CSSProperties`:应用到 DOM `<canvas>` 的样式(逻辑尺寸、display 等)
55
+ - `children?: ReactNode`:场景树
56
+
57
+ ### `View`
58
+
59
+ 布局容器(Yoga)+ 可选背景/边框 + 可选滚动。
60
+
61
+ 常用 props:
62
+
63
+ - `style?: YogaStyle`:布局样式,见下方「布局(Yoga 子集)」
64
+ - `background?: string`:背景填充色
65
+ - `border?: string`:类似 CSS 的 border,支持:
66
+ - `"<number>px solid <color>"`(例如 `1px solid rgba(255,255,255,0.2)`)
67
+ - `"<number> <color>"`(例如 `2 #fff`)
68
+ - `"<color>"`(等价于 `1px`,例如 `#fff`)
69
+ - `borderRadius?: number`
70
+ - `scrollX?: boolean`、`scrollY?: boolean`:开启滚动与裁剪
71
+ - `scrollbarX?: boolean`、`scrollbarY?: boolean`:是否显示滚动条(开启滚动时默认 `true`)
72
+ - `scrollbarWidth?: number`(默认 `10`)
73
+ - `scrollbarInset?: number`(默认 `6`)
74
+ - `scrollbarTrackColor?: string`(默认 `rgba(255,255,255,0.12)`)
75
+ - `scrollbarThumbColor?: string`(默认 `rgba(255,255,255,0.35)`)
76
+ - `onScrollX?: (scrollLeft: number) => void`
77
+ - `onScroll?: (scrollTop: number) => void`
78
+
79
+ ### `Rect`
80
+
81
+ 圆角矩形图元(也可以作为容器包含 children)。
82
+
83
+ 常用 props:
84
+
85
+ - `style?: YogaStyle`
86
+ - `fill?: string`(默认 `#ffffff`)
87
+ - `stroke?: string`
88
+ - `lineWidth?: number`(默认 `1`)
89
+ - `borderRadius?: number`
90
+
91
+ ### `Text`
92
+
93
+ 文本图元。使用 `text`,不要传 children。
94
+
95
+ 常用 props:
96
+
97
+ - `text: string`(必填)。支持 `\n` 手动换行。
98
+ - `style?: YogaStyle`:支持 `fontSize/fontFamily/fontWeight/lineHeight`(也会从祖先 / `Canvas` 默认值继承)
99
+ - `color?: string`(默认 `#ffffff`)
100
+ - `maxWidth?: number`:用于布局测量时限制测得的宽度(绘制阶段不会自动换行)
101
+
102
+ ### `Image`
103
+
104
+ 图片图元。使用 `src`,不要传 children。
105
+
106
+ 常用 props:
107
+
108
+ - `src: string`(必填)
109
+ - `style?: YogaStyle`
110
+ - `objectFit?: 'cover' | 'contain' | 'fill'`(默认 `contain`)
111
+ - `borderRadius?: number`
112
+
113
+ ## 事件
114
+
115
+ `View` / `Rect` / `Text` / `Image` 支持 pointer 事件:
116
+
117
+ - 命中测试受 `pointerEvents?: 'auto' | 'none'` 影响(`none` 会让节点对命中测试“透明”)。
118
+ - 支持冒泡 + 捕获:`onPointerDown/Move/Up/Cancel`、`onClick`,以及对应的 `*Capture`。
119
+ - Hover 事件:`onPointerEnter`、`onPointerLeave`(不走捕获/冒泡)。
120
+ - `click` 会在 `pointerup` 时触发:按下与抬起命中的是同一节点且 `button === 0`。
121
+
122
+ 如果在事件回调里调用 `event.preventDefault()`,底层 `<canvas>` 对应的 DOM 事件会尽可能执行 `preventDefault()`。
41
123
 
42
124
  ## 布局(Yoga 子集)
43
125
 
@@ -47,6 +129,7 @@ export function Example() {
47
129
  - Flex:`flexDirection/justifyContent/alignItems/flexGrow/flexShrink/flexBasis/flexWrap/gap`
48
130
  - 边距:`padding*`、`margin*`
49
131
  - 定位:`position/top/right/bottom/left`
132
+ - 文本样式(供 `Text` 使用):`fontSize/fontFamily/fontWeight/lineHeight`
50
133
 
51
134
  ## 实现说明
52
135
 
package/dist/index.cjs CHANGED
@@ -94,14 +94,56 @@ function drawRoundedRect(ctx, x, y, w, h, r) {
94
94
  function rectsIntersect(ax, ay, aw, ah, bx, by, bw, bh) {
95
95
  return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by;
96
96
  }
97
+ function resolveBorder(value) {
98
+ if (typeof value !== "string") return null;
99
+ const raw = value.trim();
100
+ if (!raw) return null;
101
+ const normalized = raw.replace(/\s+/g, " ");
102
+ const match = normalized.match(/^([0-9]*\.?[0-9]+)(px)?\s+(?:solid\s+)?(.+)$/i);
103
+ if (match) {
104
+ const width = Number(match[1]);
105
+ const color = match[3]?.trim();
106
+ if (Number.isFinite(width) && width > 0 && color) return { width, color };
107
+ return null;
108
+ }
109
+ const withoutSolid = normalized.replace(/\bsolid\b/gi, " ").replace(/\s+/g, " ").trim();
110
+ const match2 = withoutSolid.match(/^([0-9]*\.?[0-9]+)(px)?\s+(.+)$/i);
111
+ if (match2) {
112
+ const width = Number(match2[1]);
113
+ const color = match2[3]?.trim();
114
+ if (Number.isFinite(width) && width > 0 && color) return { width, color };
115
+ return null;
116
+ }
117
+ if (/^[0-9]/.test(withoutSolid)) return null;
118
+ if (!withoutSolid) return null;
119
+ return { width: 1, color: withoutSolid };
120
+ }
121
+ function drawBorder(ctx, x, y, w, h, radius, border) {
122
+ if (!Number.isFinite(border.width) || border.width <= 0) return;
123
+ const inset = border.width / 2;
124
+ const bw = w - border.width;
125
+ const bh = h - border.width;
126
+ if (bw <= 0 || bh <= 0) return;
127
+ ctx.save();
128
+ ctx.strokeStyle = border.color;
129
+ ctx.lineWidth = border.width;
130
+ drawRoundedRect(ctx, x + inset, y + inset, bw, bh, Math.max(0, radius - inset));
131
+ ctx.stroke();
132
+ ctx.restore();
133
+ }
97
134
  function drawNode(state, node, offsetX, offsetY) {
98
135
  const { ctx } = state;
99
136
  const x = offsetX + node.layout.x;
100
137
  const y = offsetY + node.layout.y;
101
138
  const w = node.layout.width;
102
139
  const h = node.layout.height;
140
+ let viewBorder = null;
141
+ let viewRadius = 0;
142
+ let viewIsScroll = false;
103
143
  if (node.type === "View") {
104
144
  const background = node.props.background;
145
+ viewBorder = resolveBorder(node.props.border);
146
+ viewRadius = node.props.borderRadius ?? 0;
105
147
  const scrollX = !!node.props?.scrollX;
106
148
  const scrollY = !!node.props?.scrollY;
107
149
  const scrollLeft = scrollX ? node.scrollLeft ?? 0 : 0;
@@ -117,14 +159,14 @@ function drawNode(state, node, offsetX, offsetY) {
117
159
  const maxScrollLeft = Math.max(0, contentWidth - w);
118
160
  const maxScrollTop = Math.max(0, contentHeight - h);
119
161
  if (background) {
120
- const radius = node.props.borderRadius ?? 0;
121
162
  ctx.save();
122
163
  ctx.fillStyle = background;
123
- drawRoundedRect(ctx, x, y, w, h, radius);
164
+ drawRoundedRect(ctx, x, y, w, h, viewRadius);
124
165
  ctx.fill();
125
166
  ctx.restore();
126
167
  }
127
168
  if (scrollX || scrollY) {
169
+ viewIsScroll = true;
128
170
  ctx.save();
129
171
  ctx.beginPath();
130
172
  ctx.rect(x, y, w, h);
@@ -201,6 +243,7 @@ function drawNode(state, node, offsetX, offsetY) {
201
243
  ctx.fill();
202
244
  ctx.restore();
203
245
  }
246
+ if (viewBorder) drawBorder(ctx, x, y, w, h, viewRadius, viewBorder);
204
247
  return;
205
248
  }
206
249
  }
@@ -240,6 +283,7 @@ function drawNode(state, node, offsetX, offsetY) {
240
283
  const { imageInstance } = node;
241
284
  if (imageInstance && imageInstance.complete && imageInstance.naturalWidth > 0) {
242
285
  const objectFit = node.props.objectFit || "contain";
286
+ const radius = node.props.borderRadius ?? 0;
243
287
  const srcW = imageInstance.naturalWidth;
244
288
  const srcH = imageInstance.naturalHeight;
245
289
  let dstX = x;
@@ -267,7 +311,7 @@ function drawNode(state, node, offsetX, offsetY) {
267
311
  }
268
312
  ctx.save();
269
313
  ctx.beginPath();
270
- drawRoundedRect(ctx, x, y, w, h, 0);
314
+ drawRoundedRect(ctx, x, y, w, h, radius);
271
315
  ctx.clip();
272
316
  ctx.drawImage(imageInstance, srcX, srcY, finalSrcW, finalSrcH, dstX, dstY, dstW, dstH);
273
317
  ctx.restore();
@@ -276,6 +320,9 @@ function drawNode(state, node, offsetX, offsetY) {
276
320
  for (const child of node.children) {
277
321
  drawNode(state, child, x, y);
278
322
  }
323
+ if (node.type === "View" && !viewIsScroll && viewBorder) {
324
+ drawBorder(ctx, x, y, w, h, viewRadius, viewBorder);
325
+ }
279
326
  }
280
327
  function drawTree(root, ctx, dpr, clearColor, defaults) {
281
328
  const w = ctx.canvas.width;