├── .github
└── workflows
│ └── publish.yml
├── .gitignore
├── .vscode
└── settings.json
├── README.md
├── package.json
├── script
└── create.js
├── src
├── compiler
│ ├── compile.js
│ ├── filter-template.js
│ └── parse-style.js
└── runtime
│ ├── animation.js
│ ├── bind-data.js
│ ├── bind-style.js
│ ├── canvas.js
│ ├── component
│ ├── _component.js
│ ├── button-component.js
│ ├── checkbox-component.js
│ ├── color-component.js
│ ├── custom-component.js
│ ├── div-component.js
│ ├── image-component.js
│ ├── input-component.js
│ ├── radio-component.js
│ ├── select-component.js
│ ├── slider-component.js
│ ├── switch-component.js
│ ├── template-component.js
│ └── text-component.js
│ ├── construct.js
│ ├── gesture.js
│ ├── layout.js
│ ├── load-image.js
│ ├── main.js
│ ├── mount-children.js
│ └── pen.js
└── template
├── .vscode
└── settings.json
├── package.json
├── public
└── index.html
├── script
└── build.js
└── src
└── main.ui
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | on:
2 | push:
3 | tags:
4 | - 'v*'
5 |
6 | jobs:
7 | publish:
8 | runs-on: ubuntu-latest
9 | steps:
10 | - uses: actions/checkout@v3
11 | - uses: actions/setup-node@v3
12 | with:
13 | node-version: '16.x'
14 | registry-url: 'https://registry.npmjs.org'
15 | - run: npm publish
16 | env:
17 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
18 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "files.associations": {
3 | "*.ui": "html"
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # CanvasUI
2 | `CanvasUI` is a canvas-based UI framework.
3 |
4 | [Online Demo (a page with only one <canvas>)](https://canvasui.js.org/example/)
5 |
6 |
7 | ## Introduction
8 | `CanvasUI` re-implements key technologies of modern front-end development. It is based on HTML <canvas>, just using Vanilla JS, no external dependency, it contains:
9 | - Declarative tags (e.g. <button>, <image>, and even <input>)
10 | - Layout engine that supports Flex
11 | - Commonly used CSS
12 | - MVVM
13 | - Timeline and Animation
14 | - Mobile-compatible gestures
15 | - Scaffolding out of the box and development server that supports hot reload
16 |
17 |
18 | ## Usage
19 | 1. Create a project with `npx canvasui project-name`
20 | 2. Use vscode to open the project root directory to enjoy code highlighting
21 | 3. Run `npm run build` in the root directory of the project to start the development server (the code will also be packaged into the `/public` directory)
22 | 4. Use a browser to visit `http://127.0.0.1:3000/`
23 | 5. Modify `main.ui` in the `/src` directory, the browser will update and modify in real time (and package it to the `/public` directory at the same time)
24 |
25 |
26 | ## Sample code
27 | ```html
28 |
37 |
38 |
39 |
40 |
41 |
42 |
48 | ```
49 |
50 |
51 | ## Built-in components
52 |
53 | component name | supported props | supported CSS |
54 | <button> | label, @click | |
55 | <checkbox> | value, label | |
56 | <color> | value | |
57 | <div> | | width, height, padding, margin, border, border-radius, background |
58 | <image> | path | width, height, border-radius |
59 | <input> | value, hint | width |
60 | <radio> | value, label, option | |
61 | <select> | value, options | |
62 | <slider> | value | width |
63 | <switch> | value | |
64 | <template> | | width, height |
65 | <text> | content | font-size, color |
66 |
67 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "canvasui",
3 | "version": "0.1.1",
4 | "description": "CanvasUI is a canvas-based UI framework.",
5 | "bin": "./script/create.js",
6 | "author": "Zhilin Liu",
7 | "license": "Anti 996"
8 | }
9 |
--------------------------------------------------------------------------------
/script/create.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | const fs = require('fs')
3 | const path = require('path')
4 |
5 |
6 | function copy(source, target) {
7 | if (!fs.existsSync(target)) {
8 | fs.mkdirSync(target, { recursive: true })
9 | }
10 | if (fs.lstatSync(source).isDirectory()) {
11 | let files = fs.readdirSync(source)
12 | for (let file of files) {
13 | let newSource = `${source}/${file}`
14 | if (fs.lstatSync(newSource).isDirectory()) {
15 | copy(newSource, `${target}/${file}`)
16 | } else {
17 | copy(newSource, target)
18 | }
19 | }
20 | } else {
21 | fs.writeFileSync(`${target}/${path.basename(source)}`, fs.readFileSync(source))
22 | }
23 | }
24 |
25 |
26 | function pack(source) {
27 | let code = ''
28 | if (fs.lstatSync(source).isDirectory()) {
29 | let files = fs.readdirSync(source).filter((item) => item !== '.DS_Store').sort()
30 | for (let file of files) {
31 | code += pack(`${source}/${file}`)
32 | }
33 | return code
34 | } else {
35 | return fs.readFileSync(source, 'utf8') + '\n\n'
36 | }
37 | }
38 |
39 |
40 | function create() {
41 | let name = process.argv[2]
42 | copy(path.join(__dirname, '../template'), name)
43 | copy(path.join(__dirname, '../src/compiler'), `${name}/compiler`)
44 | let src = pack(path.join(__dirname, '../src/runtime'))
45 | fs.writeFileSync(`${name}/public/canvasui.js`, src)
46 | }
47 |
48 |
49 | create()
50 |
--------------------------------------------------------------------------------
/src/compiler/compile.js:
--------------------------------------------------------------------------------
1 | const parseStyle = require('./parse-style')
2 | const filterTemplate = require('./filter-template')
3 |
4 |
5 | function compile(sourceCode) {
6 | let stack = [{ type: 'component', children: [] }]
7 | let state = atData
8 | let currentToken = null
9 | let currentProp = null
10 | let currentCode = null
11 | let row = 1
12 | let column = 0
13 | return parse()
14 |
15 | function parse() {
16 | for (let char of sourceCode) {
17 | column += 1
18 | if (char === '\n') {
19 | row += 1
20 | column = 0
21 | }
22 | state = state(char)
23 | if (state === void 0) {
24 | return error(`Compile Error: Unexpected token '${char}' at [line: ${row}, column: ${column}]`)
25 | }
26 | if (currentToken?.tagName === 'script' && state === atData) {
27 | state = atScript
28 | }
29 | }
30 | let children = stack[0].children
31 | let script = children.find(child => child.tagName === 'script')?.children[0]?.content.trim() ?? ''
32 | let template = children.find(child => child.tagName === 'template') ?? {
33 | type: 'component',
34 | tagName: 'template',
35 | props: {},
36 | children: [],
37 | parent: { type: 'component' },
38 | style: {},
39 | }
40 | let style = children.find(child => child.tagName === 'style')?.children[0]?.content.trim() ?? ''
41 | if (stack.length > 1) {
42 | return error('Compile Error: You may have unclosed tags')
43 | }
44 | return {
45 | script: script,
46 | template: filterTemplate(template),
47 | style: parseStyle(style),
48 | }
49 | }
50 |
51 | function emitToken() {
52 | let token = currentToken
53 | let top = stack[stack.length - 1]
54 | if (token.type !== 'code') {
55 | currentCode = null
56 | }
57 | if (token.type === 'startTag') {
58 | let parent = JSON.parse(JSON.stringify(top))
59 | delete parent.children
60 | let component = {
61 | type: 'component',
62 | tagName: token.tagName,
63 | props: token.props,
64 | children: [],
65 | parent: parent,
66 | style: {},
67 | }
68 | top.children.push(component)
69 | stack.push(component)
70 | } else if (token.type === 'code') {
71 | if (currentCode === null) {
72 | currentCode = {
73 | content: '',
74 | }
75 | top.children.push(currentCode)
76 | }
77 | currentCode.content += token.content
78 | } else if (token.type === 'endTag' && top.tagName === token.tagName) {
79 | stack.pop()
80 | }
81 | currentToken = null
82 | }
83 |
84 | function error(message) {
85 | return { error: message }
86 | }
87 |
88 | function atData(char) {
89 | if (char === '<') {
90 | return atTagOpen
91 | } else {
92 | currentToken = {
93 | type: 'code',
94 | content: char,
95 | }
96 | emitToken()
97 | return atData
98 | }
99 | }
100 |
101 | function atTagOpen(char) {
102 | if (char.match(/^[a-zA-Z0-9]$/)) {
103 | currentToken = {
104 | type: 'startTag',
105 | tagName: '',
106 | props: {},
107 | }
108 | return atTagName(char)
109 | } else if (char === '/') {
110 | return atEndTagOpen
111 | }
112 | }
113 |
114 | function atTagName(char) {
115 | if (char.match(/^[a-zA-Z0-9]$/)) {
116 | currentToken.tagName += char
117 | return atTagName
118 | } else if (char.match(/^[\t\n ]$/)) {
119 | return atBeforePropName
120 | } else if (char === '>') {
121 | emitToken()
122 | return atData
123 | }
124 | }
125 |
126 | function atEndTagOpen(char) {
127 | if (char.match(/^[a-zA-Z0-9]$/)) {
128 | currentToken = {
129 | type: 'endTag',
130 | tagName: '',
131 | }
132 | return atTagName(char)
133 | }
134 | }
135 |
136 | function atBeforePropName(char) {
137 | if (char.match(/^[a-zA-Z-@]$/)) {
138 | currentProp = {
139 | name: '',
140 | value: '',
141 | }
142 | return atPropName(char)
143 | } else if (char.match(/^[\t\n ]$/)) {
144 | return atBeforePropName
145 | } else if (char === '>') {
146 | return atAfterPropName(char)
147 | }
148 | }
149 |
150 | function atPropName(char) {
151 | if (char.match(/^[\t\n ]$/) || char === '>') {
152 | return atAfterPropName(char)
153 | } else if (char === '=') {
154 | return atBeforePropValue
155 | } else {
156 | currentProp.name += char
157 | return atPropName
158 | }
159 | }
160 |
161 | function atAfterPropName(char) {
162 | if (char.match(/^[\t\n ]$/)) {
163 | return atAfterPropName
164 | } else if (char === '=') {
165 | return atBeforePropValue
166 | } else if (char === '>') {
167 | if (currentProp) {
168 | currentToken.props[currentProp.name] = currentProp.value
169 | }
170 | emitToken()
171 | return atData
172 | }
173 | }
174 |
175 | function atBeforePropValue(char) {
176 | if (char.match(/^[\t\n ]$/)) {
177 | return atBeforePropValue
178 | } else if (char === '"') {
179 | return atPropValue
180 | }
181 | }
182 |
183 | function atPropValue(char) {
184 | if (char === '"') {
185 | currentToken.props[currentProp.name] = currentProp.value
186 | return atAfterPropValue
187 | } else {
188 | currentProp.value += char
189 | return atPropValue
190 | }
191 | }
192 |
193 | function atAfterPropValue(char) {
194 | if (char.match(/^[\t\n ]$/)) {
195 | return atBeforePropName
196 | } else if (char === '>') {
197 | emitToken()
198 | return atData
199 | }
200 | }
201 |
202 | function atScript(char) {
203 | if (char === '<') {
204 | return atScriptEndTag1
205 | } else {
206 | currentToken = {
207 | type: 'code',
208 | content: char,
209 | }
210 | emitToken()
211 | return atScript
212 | }
213 | }
214 | // <
215 | function atScriptEndTag1(char) {
216 | if (char === '/') {
217 | return atScriptEndTag2
218 | } else {
219 | currentToken = {
220 | type: 'code',
221 | content: `<${char}`,
222 | }
223 | emitToken()
224 | return atScript
225 | }
226 | }
227 | //
228 | function atScriptEndTag2(char) {
229 | if (char === 's') {
230 | return atScriptEndTag3
231 | } else {
232 | currentToken = {
233 | type: 'code',
234 | content: `${char}`,
235 | }
236 | emitToken()
237 | return atScript
238 | }
239 | }
240 | // ') {
308 | currentToken = {
309 | type: 'endTag',
310 | tagName: 'script',
311 | }
312 | emitToken()
313 | return atData
314 | } else {
315 | currentToken = {
316 | type: 'code',
317 | content: ` child.type === 'component')
3 | for (let child of component.children) {
4 | filterTemplate(child)
5 | }
6 | return component
7 | }
8 |
9 |
10 | module.exports = filterTemplate
11 |
--------------------------------------------------------------------------------
/src/compiler/parse-style.js:
--------------------------------------------------------------------------------
1 | function parseStyle(style) {
2 | let rules = []
3 | let selector = ''
4 | let declaration = {}
5 | let property = ''
6 | let value = ''
7 | return parse()
8 |
9 | function parse() {
10 | let state = atSelector
11 | for (let char of style) {
12 | state = state(char)
13 | }
14 | return rules
15 | }
16 |
17 | function atSelector(char) {
18 | if (char === '{') {
19 | return atProperty
20 | } else {
21 | selector += char
22 | return atSelector
23 | }
24 | }
25 |
26 | function atProperty(char) {
27 | if (char === ':') {
28 | return atValue
29 | } else if (char === '}') {
30 | rules.push({
31 | selector: selector.trim(),
32 | declaration: declaration,
33 | })
34 | selector = ''
35 | declaration = {}
36 | return atSelector
37 | } else {
38 | property += char
39 | return atProperty
40 | }
41 | }
42 |
43 | function atValue(char) {
44 | if (char === ';') {
45 | declaration[property.trim()] = value.trim()
46 | property = ''
47 | value = ''
48 | return atProperty
49 | } else {
50 | value += char
51 | return atValue
52 | }
53 | }
54 | }
55 |
56 |
57 | module.exports = parseStyle
58 |
--------------------------------------------------------------------------------
/src/runtime/animation.js:
--------------------------------------------------------------------------------
1 | /*
2 | 用法:
3 | let animation = new Animation(config)
4 | let animation2 = new Animation(config)
5 |
6 | let timeline = new Timeline()
7 | timeline.add(animation)
8 | timeline.add(animation2)
9 |
10 | timeline.start()
11 | timeline.pause()
12 | timeline.resume()
13 | */
14 |
15 |
16 | class Timeline {
17 | constructor() {
18 | this.state = 'initial' // initial | running | paused | waiting(已经start, 等待add)
19 | this.rafId = 0
20 | this.startTime = 0
21 | this.pauseTime = 0
22 | this.animations = new Set()
23 | }
24 |
25 | tick() {
26 | let elapsedTime = Date.now() - this.startTime
27 | for (let animation of this.animations) {
28 | let { object, property, template, duration, delay, timingFunction } = animation
29 | let progress
30 | if (elapsedTime < delay) {
31 | progress = 0
32 | } else if (elapsedTime <= delay + duration) {
33 | // timingFunction 接受时间的进度(0~1), 返回实际效果的进度(0~1)
34 | progress = timingFunction((elapsedTime - delay) / duration)
35 | } else {
36 | progress = 1
37 | this.animations.delete(animation)
38 | }
39 | // 根据进度计算相应的属性值
40 | let value = animation.newValue(progress)
41 | if (object[property]) {
42 | object[property].value = template(value)
43 | } else {
44 | object[property] = { value: template(value) }
45 | }
46 | }
47 | if (this.animations.size > 0) {
48 | this.rafId = requestAnimationFrame(this.tick.bind(this))
49 | } else {
50 | // 已经 start, 等待 add 后立即开始 tick
51 | this.state = 'waiting'
52 | }
53 | }
54 |
55 | add(animation) {
56 | this.animations.add(animation)
57 | if (this.state !== 'initial') {
58 | // 可以随时开启一个新的 animation
59 | animation.delay += Date.now() - this.startTime
60 | }
61 | if (this.state === 'waiting') {
62 | // timeline 已经 start, 当 animations 中有元素的时候立即开始 tick
63 | this.state = 'running'
64 | this.tick()
65 | }
66 | }
67 |
68 | start() {
69 | if (this.state !== 'initial') {
70 | return
71 | }
72 | this.state = 'running'
73 | this.startTime = Date.now()
74 | this.tick()
75 | }
76 |
77 | pause() {
78 | if (this.state !== 'running') {
79 | return
80 | }
81 | this.state = 'paused'
82 | this.pauseTime = Date.now()
83 | cancelAnimationFrame(this.rafId)
84 | }
85 |
86 | resume() {
87 | if (this.state !== 'paused') {
88 | return
89 | }
90 | this.state = 'running'
91 | this.startTime += Date.now() - this.pauseTime
92 | this.tick()
93 | }
94 |
95 | reset() {
96 | this.animations = new Set()
97 | this.state = 'initial'
98 | }
99 | }
100 |
101 |
102 | class Animation {
103 | constructor(config) {
104 | this.object = null
105 | this.id = config.id
106 | this.property = config.property
107 | this.template = config.template
108 | this.start = config.start
109 | this.end = config.end
110 | this.duration = config.duration
111 | this.delay = config.delay || 0
112 | this.timingFunction = config.timingFunction || (progress => progress)
113 | this.find(config.component)
114 | }
115 |
116 | find(component) {
117 | if (component.props?.id === this.id) {
118 | this.object = component.style
119 | return
120 | }
121 | for (let child of component.children) {
122 | this.find(child)
123 | }
124 | }
125 |
126 | newValue(progress) {
127 | // 不同的 animation 需要重写此方法
128 | return this.start + (this.end - this.start) * progress
129 | }
130 | }
131 |
132 |
133 | function cubicBezier(x1, y1, x2, y2) {
134 | // 翻译自 WebKit 源码:
135 | // https://github.com/WebKit/WebKit/blob/main/Source/WebCore/platform/graphics/UnitBezier.h
136 |
137 | const epsilon = 1e-6
138 |
139 | const ax = 3 * x1 - 3 * x2 + 1
140 | const bx = 3 * x2 - 6 * x1
141 | const cx = 3 * x1
142 |
143 | const ay = 3 * y1 - 3 * y2 + 1
144 | const by = 3 * y2 - 6 * y1
145 | const cy = 3 * y1
146 |
147 | function sampleCurveX(t) {
148 | return ((ax * t + bx) * t + cx ) * t
149 | }
150 |
151 | function sampleCurveY(t) {
152 | return ((ay * t + by) * t + cy ) * t
153 | }
154 |
155 | function sampleCurveDerivativeX(t) {
156 | return (3 * ax * t + 2 * bx) * t + cx
157 | }
158 |
159 | function solveCurveX(x) {
160 | let t0 = 0
161 | let t1 = 1
162 | let t2 = x
163 | let x2
164 | // 先尝试非常快的牛顿法
165 | for (let i = 0; i < 8; i++) {
166 | x2 = sampleCurveX(t2) - x
167 | if (Math.abs(x2) < epsilon) {
168 | return t2
169 | }
170 | let derivative = sampleCurveDerivativeX(t2)
171 | if (Math.abs(derivative) < epsilon) {
172 | break
173 | }
174 | t2 -= x2 / derivative
175 | }
176 | // 回到更可靠的二分法
177 | while (t1 > t0) {
178 | x2 = sampleCurveX(t2) - x
179 | if (Math.abs(x2) < epsilon) {
180 | return t2
181 | }
182 | if (x2 > 0) {
183 | t1 = t2
184 | } else {
185 | t0 = t2
186 | }
187 | t2 = (t1 + t0) / 2
188 | }
189 | return t2
190 | }
191 |
192 | function solve(x) {
193 | return sampleCurveY(solveCurveX(x))
194 | }
195 |
196 | return solve
197 | }
198 |
199 |
200 | let timeline = new Timeline()
201 |
--------------------------------------------------------------------------------
/src/runtime/bind-data.js:
--------------------------------------------------------------------------------
1 | function bindData(component, style, script) {
2 | let rootComponent = component
3 | let proxyCallbackMap = new Map()
4 | let currentProxyCallback = null
5 | // data & methods
6 | function init(callback) {
7 | component.initQueue.push(callback)
8 | }
9 | let config = Object.entries(eval(`(function() {${script}})()`))
10 | let data = config.filter(item => typeof item[1] !== 'function')
11 | let methods = config.filter(item => typeof item[1] === 'function')
12 | let vm = proxy(Object.fromEntries(data))
13 | vm.component = component
14 | for (let method of methods) {
15 | let [_name, _body] = method
16 | with(vm) {
17 | vm[_name] = (...args) => {
18 | eval(`(${_body})(...args)`)
19 | }
20 | }
21 | }
22 | // 遍历 component 及其子组件, 收集依赖(对使用模板语法的 prop 注册回调函数)
23 | // 并绑定样式和设置盒模型
24 | traverse(rootComponent)
25 |
26 | function proxy(object) {
27 | return new Proxy(object, {
28 | get(object, property) {
29 | if (typeof object[property] === 'object' && !Array.isArray(object[property])) {
30 | return proxy(object[property])
31 | } else {
32 | if (currentProxyCallback) {
33 | if (!proxyCallbackMap.has(object)) {
34 | proxyCallbackMap.set(object, new Map())
35 | }
36 | if (!proxyCallbackMap.get(object).has(property)) {
37 | proxyCallbackMap.get(object).set(property, new Set())
38 | }
39 | proxyCallbackMap.get(object).get(property).add(currentProxyCallback)
40 | }
41 | return object[property]
42 | }
43 | },
44 | set(object, property, value) {
45 | object[property] = value
46 | let callbacks = proxyCallbackMap.get(object)?.get(property) ?? (new Set())
47 | for (let callback of callbacks) {
48 | callback()
49 | }
50 | return true
51 | }
52 | })
53 | }
54 |
55 | function registerProxyCallback(callback) {
56 | currentProxyCallback = callback
57 | callback(true)
58 | currentProxyCallback = null
59 | }
60 |
61 | function traverse(component) {
62 | for (let [prop, value] of Object.entries(component.props)) {
63 | if (value.match(/{([\s\S]+)}/)) {
64 | let name = RegExp.$1.trim()
65 | registerProxyCallback((registering) => {
66 | // registering 用于标记当前是正在收集依赖还是真正触发了改动
67 | // 有些操作必须是真正触发了改动了才会执行, 并不会在收集依赖的时候就执行
68 | if (typeof vm[name] === 'function' || Array.isArray(vm[name])) {
69 | component.props[prop] = vm[name]
70 | } else {
71 | component.props[prop] = value.replace(/{([\s\S]+)}/, vm[name])
72 | if (prop === 'value') {
73 | // 对于特殊的 input 类的组件, 当 UI 操作使其发生变化的时候, 也将相应的变化流向 vm
74 | // 此处存下所绑定的变量名, 在组件中即可使用 this.vm[this.bind] = this.value 的方式传递数据
75 | component.bind = name
76 | }
77 | }
78 | // 在属性发生改变的时候 重新绑定样式 / 重新设置盒模型 / 重新排版
79 | bindStyle(component, style)
80 | component.setBox()
81 | if (!registering) {
82 | // 真正触发改动的时候才发生重排
83 | layout(rootComponent, true)
84 | }
85 | })
86 | }
87 | }
88 | component.vm = vm
89 | bindStyle(component, style)
90 | component.setBox()
91 | for (let child of component.children) {
92 | traverse(child)
93 | }
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/src/runtime/bind-style.js:
--------------------------------------------------------------------------------
1 | function bindStyle(component, style) {
2 | traverse(component)
3 |
4 | function traverse(component) {
5 | compute(component)
6 | for (let child of component.children) {
7 | traverse(child)
8 | }
9 | }
10 |
11 | function match(component, selector) {
12 | if (selector[0] === '#') {
13 | let id = component.props.id
14 | if (id === selector.slice(1)) {
15 | return true
16 | }
17 | } else if (selector[0] === '.') {
18 | let classNames = component.props.class?.split(' ') ?? []
19 | for (let className of classNames) {
20 | if (className === selector.slice(1)) {
21 | return true
22 | }
23 | }
24 | } else if (selector === component.tagName) {
25 | return true
26 | } else {
27 | return false
28 | }
29 | }
30 |
31 | function specificity(selectors) {
32 | let s = [0, 0, 0, 0]
33 | for (let part of selectors) {
34 | if (part[0] === '#') {
35 | s[1] += 1
36 | } else if (part[0] === '.') {
37 | s[2] += 1
38 | } else {
39 | s[3] += 1
40 | }
41 | }
42 | return s
43 | }
44 |
45 | function compare(s1, s2) {
46 | if (s1[0] !== s2[0]) {
47 | return s1[0] > s2[0]
48 | } else if (s1[1] !== s2[1]) {
49 | return s1[1] > s2[1]
50 | } else if (s1[2] !== s2[2]) {
51 | return s1[2] > s2[2]
52 | } else if (s1[3] !== s2[3]) {
53 | return s1[3] - s2[3]
54 | } else {
55 | return false
56 | }
57 | }
58 |
59 | function compute(component) {
60 | component.style = {}
61 | ruleLoop:
62 | for (let rule of style) {
63 | let selectors = rule.selector.split(' ').reverse()
64 | let parent = component
65 | for (let part of selectors) {
66 | if (match(parent, part)) {
67 | parent = parent.parent
68 | } else {
69 | continue ruleLoop
70 | }
71 | }
72 | let newSpecificity = specificity(selectors)
73 | let style = component.style
74 | for (let [property, value] of Object.entries(rule.declaration)) {
75 | style[property] = style[property] ?? {}
76 | style[property].specificity = style[property].specificity ?? newSpecificity
77 | if (compare(style[property].specificity, newSpecificity)) {
78 | // 如果原样式比新样式的优先级更高, 则无需改变
79 | continue
80 | }
81 | // 后来优先原则
82 | style[property].value = value
83 | }
84 | }
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/src/runtime/canvas.js:
--------------------------------------------------------------------------------
1 | class Canvas {
2 | constructor() {
3 | this.canvas = document.querySelector('canvas')
4 | this.context = this.canvas.getContext('2d')
5 | this.component = null
6 | this.responsive()
7 | this.setDefaultCursor()
8 | }
9 |
10 | launch(component) {
11 | this.component = component
12 | this.component.initQueue.forEach(callback => callback())
13 | requestAnimationFrame(this.mainloop.bind(this))
14 | }
15 |
16 | mainloop() {
17 | this.context.clearRect(0, 0, this.canvas.width, this.canvas.height)
18 | this.draw(this.component)
19 | requestAnimationFrame(this.mainloop.bind(this))
20 | }
21 |
22 | draw(component) {
23 | component.draw()
24 | for (let child of component.children) {
25 | this.draw(child)
26 | }
27 | }
28 |
29 | responsive() {
30 | let timer = null
31 | let resize = () => {
32 | let dpr = window.devicePixelRatio
33 | if ('ontouchstart' in document) {
34 | dpr = 1
35 | }
36 | this.canvas.width = window.innerWidth * dpr
37 | this.canvas.height = window.innerHeight * dpr
38 | this.canvas.style.width = `${window.innerWidth}px`
39 | this.canvas.style.height = `${window.innerHeight}px`
40 | this.context.scale(dpr, dpr)
41 | }
42 | resize()
43 | window.addEventListener('resize', () => {
44 | clearTimeout(timer)
45 | timer = setTimeout(() => {
46 | resize()
47 | this.component.style['width'].value = window.innerWidth
48 | this.component.style['height'].value = window.innerHeight
49 | layout(this.component, true)
50 | }, 100)
51 | })
52 | }
53 |
54 | setDefaultCursor() {
55 | document.addEventListener('mousemove', () => {
56 | document.body.style.cursor = 'auto'
57 | })
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/src/runtime/component/_component.js:
--------------------------------------------------------------------------------
1 | class Component {
2 | constructor(template, context) {
3 | this.vm = null
4 | this.initQueue = []
5 | this.tagName = template.tagName
6 | this.parent = template.parent
7 | this.props = template.props
8 | this.style = template.style
9 | this.context = context
10 | this.layout = {}
11 | this.children = []
12 | }
13 |
14 | // 鼠标事件
15 | hover(enterCallback, leaveCallback) {
16 | document.addEventListener('mousemove', (event) => {
17 | if (event.clientX >= this.layout.left &&
18 | event.clientX <= this.layout.right &&
19 | event.clientY >= this.layout.top &&
20 | event.clientY <= this.layout.bottom)
21 | {
22 | enterCallback(event)
23 | } else {
24 | leaveCallback && leaveCallback(event)
25 | }
26 | })
27 | }
28 |
29 | mousedown(callback) {
30 | document.addEventListener('mousedown', (event) => {
31 | if (event.clientX >= this.layout.left &&
32 | event.clientX <= this.layout.right &&
33 | event.clientY >= this.layout.top &&
34 | event.clientY <= this.layout.bottom)
35 | {
36 | callback(event)
37 | }
38 | })
39 | }
40 |
41 | mouseup(callback) {
42 | document.addEventListener('mouseup', (event) => {
43 | if (event.clientX >= this.layout.left &&
44 | event.clientX <= this.layout.right &&
45 | event.clientY >= this.layout.top &&
46 | event.clientY <= this.layout.bottom)
47 | {
48 | callback(event)
49 | }
50 | })
51 | }
52 |
53 | mousemove(callback) {
54 | document.addEventListener('mousemove', (event) => {
55 | if (event.clientX >= this.layout.left &&
56 | event.clientX <= this.layout.right &&
57 | event.clientY >= this.layout.top &&
58 | event.clientY <= this.layout.bottom)
59 | {
60 | callback(event)
61 | }
62 | })
63 | }
64 |
65 | dblclick(callback) {
66 | document.addEventListener('dblclick', (event) => {
67 | if (event.clientX >= this.layout.left &&
68 | event.clientX <= this.layout.right &&
69 | event.clientY >= this.layout.top &&
70 | event.clientY <= this.layout.bottom)
71 | {
72 | callback(event)
73 | }
74 | })
75 | }
76 |
77 | // 手势事件
78 | // 轻点
79 | tap(callback) {
80 | document.addEventListener('tap', (event) => {
81 | if (event.detail.clientX >= this.layout.left &&
82 | event.detail.clientX <= this.layout.right &&
83 | event.detail.clientY >= this.layout.top &&
84 | event.detail.clientY <= this.layout.bottom)
85 | {
86 | callback(event.detail)
87 | }
88 | })
89 | }
90 | // 长按
91 | pressstart(callback) {
92 | document.addEventListener('pressstart', (event) => {
93 | if (event.detail.clientX >= this.layout.left &&
94 | event.detail.clientX <= this.layout.right &&
95 | event.detail.clientY >= this.layout.top &&
96 | event.detail.clientY <= this.layout.bottom)
97 | {
98 | callback(event.detal)
99 | }
100 | })
101 | }
102 | pressend(callback) {
103 | document.addEventListener('pressend', (event) => {
104 | if (event.detail.clientX >= this.layout.left &&
105 | event.detail.clientX <= this.layout.right &&
106 | event.detail.clientY >= this.layout.top &&
107 | event.detail.clientY <= this.layout.bottom)
108 | {
109 | callback(event.detail)
110 | }
111 | })
112 | }
113 | // 拖动
114 | panstart(callback) {
115 | document.addEventListener('panstart', (event) => {
116 | if (event.detail.startX >= this.layout.left &&
117 | event.detail.startX <= this.layout.right &&
118 | event.detail.startY >= this.layout.top &&
119 | event.detail.startY <= this.layout.bottom)
120 | {
121 | callback(event.detail)
122 | }
123 | })
124 | }
125 | panmove(callback) {
126 | document.addEventListener('panmove', (event) => {
127 | if (event.detail.clientX >= this.layout.left &&
128 | event.detail.clientX <= this.layout.right &&
129 | event.detail.clientY >= this.layout.top &&
130 | event.detail.clientY <= this.layout.bottom)
131 | {
132 | callback(event.detail)
133 | }
134 | })
135 | }
136 | panend(callback) {
137 | document.addEventListener('panend', (event) => {
138 | if (event.detail.clientX >= this.layout.left &&
139 | event.detail.clientX <= this.layout.right &&
140 | event.detail.clientY >= this.layout.top &&
141 | event.detail.clientY <= this.layout.bottom)
142 | {
143 | callback(event.detail)
144 | }
145 | })
146 | }
147 | // 快扫
148 | swipe(callback) {
149 | document.addEventListener('swipe', (event) => {
150 | if (event.detail.clientX >= this.layout.left &&
151 | event.detail.clientX <= this.layout.right &&
152 | event.detail.clientY >= this.layout.top &&
153 | event.detail.clientY <= this.layout.bottom)
154 | {
155 | callback(event.detail)
156 | }
157 | })
158 | }
159 |
160 | /*
161 | 子类应实现如下方法:
162 | setBox() // 设置宽高用于排版
163 | registerEvent() // 用于注册事件
164 | draw() // 用于绘制组件
165 | */
166 | }
167 |
--------------------------------------------------------------------------------
/src/runtime/component/button-component.js:
--------------------------------------------------------------------------------
1 | class ButtonComponent extends Component {
2 | constructor(template, context) {
3 | super(template, context)
4 | this.borderColor = '#dcdfe6'
5 | this.backgroundColor = '#ffffff'
6 | this.labelColor = '#606266'
7 | this.registerEvent()
8 | }
9 |
10 | setBox() {
11 | this.style['width'] = {
12 | value: '100px',
13 | }
14 | this.style['height'] = {
15 | value: '40px',
16 | }
17 | this.context.font = '14px sans-serif'
18 | let labelWidth = this.context.measureText(this.props.label).width
19 | if (labelWidth >= 58) {
20 | this.style['width'].value = `${labelWidth + 42}px`
21 | }
22 | }
23 |
24 | registerEvent() {
25 | this.hover(() => {
26 | this.borderColor = '#c6e2ff'
27 | this.backgroundColor = '#ecf5ff'
28 | this.labelColor = '#409eff'
29 | document.body.style.cursor = 'pointer'
30 | }, () => {
31 | this.borderColor = '#dcdfe6'
32 | this.backgroundColor = '#ffffff'
33 | this.labelColor = '#606266'
34 | })
35 | this.mousedown(() => {
36 | this.borderColor = '#3a8ee6'
37 | this.labelColor = '#3a8ee6'
38 | })
39 | this.mouseup(() => {
40 | this.borderColor = '#c6e2ff'
41 | this.labelColor = '#409eff'
42 | })
43 | this.tap(() => {
44 | this.props['@click']()
45 | })
46 | }
47 |
48 | draw() {
49 | pen.reset()
50 | // 边框
51 | let width = parseInt(this.style['width'].value)
52 | let height = parseInt(this.style['height'].value)
53 | pen.drawRect(this.layout.left, this.layout.top, width, height, 4)
54 | pen.stroke(this.borderColor)
55 | // 背景
56 | pen.fill(this.backgroundColor)
57 | // 文字
58 | let x = this.layout.left + width / 2
59 | let y = this.layout.top + 13
60 | pen.drawText(this.props.label, x, y, 14, this.labelColor, 'center')
61 | }
62 | }
63 |
64 |
--------------------------------------------------------------------------------
/src/runtime/component/checkbox-component.js:
--------------------------------------------------------------------------------
1 | class CheckboxComponent extends Component {
2 | constructor(template, context) {
3 | super(template, context)
4 | this.firstDraw = true
5 | this.value = false
6 | this.borderColor = '#dcdfe6'
7 | this.registerEvent()
8 | }
9 |
10 | get backgroundColor() {
11 | return this.value ? '#409eff' : '#ffffff'
12 | }
13 |
14 | get labelColor() {
15 | return this.value ? '#409eff' : '#606266'
16 | }
17 |
18 | setBox() {
19 | this.context.font = '14px sans-serif'
20 | let labelWidth = this.context.measureText(this.props.label).width
21 | this.style['width'] = {
22 | value: `${24 + labelWidth}px`,
23 | }
24 | this.style['height'] = {
25 | value: '14px',
26 | }
27 | }
28 |
29 | registerEvent() {
30 | this.hover(() => {
31 | this.borderColor = '#409eff'
32 | document.body.style.cursor = 'pointer'
33 | }, () => {
34 | this.borderColor = '#dcdfe6'
35 | })
36 | this.tap(() => {
37 | this.value = !this.value
38 | this.vm[this.bind] = this.value
39 | })
40 | }
41 |
42 | draw() {
43 | pen.reset()
44 | if (this.firstDraw) {
45 | this.firstDraw = false
46 | // 因为传入的属性值为字符串类型, 而不是布尔类型, 所以需要进一步判断
47 | this.value = {
48 | 'true': true,
49 | 'false': false,
50 | }[this.props.value]
51 | }
52 | // 边框
53 | pen.drawRect(this.layout.left, this.layout.top, 14, 14, 2)
54 | pen.stroke(this.borderColor)
55 | // 背景
56 | pen.fill(this.backgroundColor)
57 | // 对勾
58 | if (this.value) {
59 | pen.drawText('✔', this.layout.left + 1, this.layout.top, 14, 'white')
60 | }
61 | // 文字
62 | pen.drawText(this.props.label, this.layout.left + 24, this.layout.top, 14, this.labelColor)
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/src/runtime/component/color-component.js:
--------------------------------------------------------------------------------
1 | class ColorComponent extends Component {
2 | constructor(template, context) {
3 | super(template, context)
4 | this.firstDraw = true
5 | this.value = '#409eff'
6 | this.show = false
7 | this.registerEvent()
8 | }
9 |
10 | setBox() {
11 | this.style['width'] = {
12 | value: '40px',
13 | }
14 | this.style['height'] = {
15 | value: '40px',
16 | }
17 | }
18 |
19 | registerEvent() {
20 | this.tap(() => {
21 | this.show = !this.show
22 | })
23 | this.hover(() => {
24 | document.body.style.cursor = 'pointer'
25 | })
26 | document.addEventListener('tap', (event) => {
27 | if (event.detail.clientX >= this.layout.left - 50 &&
28 | event.detail.clientX <= this.layout.right + 50 &&
29 | event.detail.clientY >= this.layout.bottom + 5 &&
30 | event.detail.clientY <= this.layout.bottom + 145)
31 | {
32 | if (this.show) {
33 | let dpr = window.devicePixelRatio
34 | if ('ontouchstart' in document) {
35 | dpr = 1
36 | }
37 | let [r, g, b] = this.context.getImageData(event.detail.clientX * dpr, event.detail.clientY * dpr, 1, 1).data
38 | this.value = `rgb(${r}, ${g}, ${b})`
39 | this.vm[this.bind] = this.value
40 | this.show = false
41 | }
42 | }
43 | })
44 | }
45 |
46 | draw() {
47 | pen.reset()
48 | if (this.firstDraw) {
49 | this.firstDraw = false
50 | if (this.props.value !== '') {
51 | this.value = this.props.value
52 | }
53 | }
54 | // 选择按钮
55 | pen.drawRect(this.layout.left, this.layout.top, 40, 40, 4)
56 | pen.stroke('#dcdfe6')
57 | pen.drawRect(this.layout.left + 5, this.layout.top + 5, 30, 30, 2)
58 | pen.fill(this.value)
59 | // 箭头
60 | pen.drawText('▽', this.layout.left + 13, this.layout.top + 13, 14, 'white')
61 | // 色盘
62 | if (this.show) {
63 | for (let i = 0; i < 12; i++) {
64 | this.context.beginPath()
65 | this.context.moveTo(this.layout.left + 20, this.layout.top + 115)
66 | this.context.arc(this.layout.left + 20, this.layout.top + 115, 70, (Math.PI * 2) / 12 * i, (Math.PI * 2) / 12 * (i + 1))
67 | this.context.closePath()
68 | this.context.fillStyle = `hsl(${i * 30}, 100%, 50%)`
69 | this.context.fill()
70 | }
71 | }
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/src/runtime/component/custom-component.js:
--------------------------------------------------------------------------------
1 | class CustomComponent extends Component {
2 | constructor(template, context) {
3 | super(template, context)
4 | }
5 |
6 | setBox() {
7 | }
8 |
9 | mount() {
10 | let { style, template, script } = componentJson[this.tagName]
11 | let component = construct(template, this.context)
12 | bindData(component, style, script)
13 | return component
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/src/runtime/component/div-component.js:
--------------------------------------------------------------------------------
1 | class DivComponent extends Component {
2 | constructor(template, context) {
3 | super(template, context)
4 | }
5 |
6 | setBox() {
7 | }
8 |
9 | draw() {
10 | pen.reset()
11 | let margin = parseInt(this.style['margin']?.value ?? 0)
12 | let borderWidth = parseInt(this.style['border']?.value.split(' ')[0] ?? 1)
13 | let borderStyle = this.style['border']?.value.split(' ')[1] ?? 'solid'
14 | let borderColor = this.style['border']?.value.split(' ')[2] ?? 'black'
15 | let radius = parseInt(this.style['border-radius']?.value ?? 0)
16 |
17 | let x = this.layout.left + margin
18 | let y = this.layout.top + margin
19 | let width = this.layout.width - margin * 2
20 | let height = this.layout.height - margin * 2
21 |
22 | pen.drawRect(x, y, width, height, radius, borderStyle, borderWidth)
23 | if (this.style.border) {
24 | pen.stroke(borderColor, borderWidth)
25 | }
26 | if (this.style.background) {
27 | pen.fill(this.style.background.value)
28 | }
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/runtime/component/image-component.js:
--------------------------------------------------------------------------------
1 | class ImageComponent extends Component {
2 | constructor(template, context) {
3 | super(template, context)
4 | this.image = null
5 | }
6 |
7 | setBox() {
8 | // 为了避免重排, 必须通过 CSS 显式设置宽高
9 | }
10 |
11 | draw() {
12 | pen.reset()
13 | let width = parseInt(this.style['width'].value)
14 | let height = parseInt(this.style['height'].value)
15 | let radius = parseInt(this.style['border-radius']?.value ?? 0)
16 | pen.drawImage(this.image, this.layout.left, this.layout.top, width, height, radius)
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/runtime/component/input-component.js:
--------------------------------------------------------------------------------
1 | class InputComponent extends Component {
2 | constructor(template, context) {
3 | super(template, context)
4 | this.focus = false
5 | this.array = []
6 | this.index = -1
7 | this.selecting = false
8 | this.selected = { start: -1, end: -1 }
9 | this.borderColor = '#dcdfe6'
10 | this.caretColor = 'black'
11 | this.registerEvent()
12 | this.caretBlink()
13 | }
14 |
15 | get value() {
16 | return this.array.join('')
17 | }
18 |
19 | get selectedValue() {
20 | let start = Math.min(this.selected.start, this.selected.end)
21 | let end = Math.max(this.selected.start, this.selected.end) + 1
22 | return this.value.slice(start, end)
23 | }
24 |
25 | get startPosition() {
26 | return this.layout.left + 15
27 | }
28 |
29 | get endPosition() {
30 | this.context.font = '14px sans-serif'
31 | let width = this.context.measureText(this.value).width
32 | return this.startPosition + width
33 | }
34 |
35 | get caretPosition() {
36 | this.context.font = '14px sans-serif'
37 | let width = this.context.measureText(this.array.slice(0, this.index + 1).join('')).width
38 | return this.startPosition + width
39 | }
40 |
41 | setBox() {
42 | this.style['width'] = this.style['width'] ?? {
43 | value: '180px',
44 | }
45 | this.style['height'] = {
46 | value: '40px',
47 | }
48 | }
49 |
50 | registerEvent() {
51 | this.hover(() => {
52 | if (!this.focus) {
53 | this.borderColor = '#c0c4cc'
54 | }
55 | document.body.style.cursor = 'text'
56 | }, () => {
57 | if (!this.focus) {
58 | this.borderColor = '#dcdfe6'
59 | }
60 | })
61 | this.tap((event) => {
62 | this.focus = true
63 | this.borderColor = '#409eff'
64 | // 鼠标位置插入光标
65 | if (event.clientX <= this.startPosition) {
66 | this.index = -1
67 | } else if (event.clientX >= this.endPosition) {
68 | this.index = this.value.length - 1
69 | } else {
70 | for (let i = 1; i < this.value.length; i++) {
71 | let width = this.context.measureText(this.value.slice(0, i)).width
72 | if (Math.abs(this.startPosition + width - event.clientX) < 7) {
73 | this.index = i - 1
74 | break
75 | }
76 | }
77 | }
78 | })
79 | this.dblclick(() => {
80 | this.selected = { start: 0, end: this.array.length - 1}
81 | })
82 | this.mousedown((event) => {
83 | this.selecting = true
84 | this.selected = { start: -1, end: -1 }
85 | if (event.clientX <= this.startPosition) {
86 | this.selected.start = 0
87 | } else if (event.clientX >= this.endPosition) {
88 | this.selected.start = this.value.length - 1
89 | } else {
90 | for (let i = 1; i < this.value.length; i++) {
91 | let width = this.context.measureText(this.value.slice(0, i)).width
92 | if (Math.abs(this.startPosition + width - event.clientX) < 7) {
93 | this.selected.start = i
94 | break
95 | }
96 | }
97 | }
98 | })
99 | this.mousemove((event) => {
100 | if (this.selecting) {
101 | if (event.clientX <= this.startPosition) {
102 | if (this.selected.start > 0) {
103 | this.selected.end = 0
104 | }
105 | } else if (event.clientX >= this.endPosition) {
106 | if (this.selected.start < this.value.length - 1) {
107 | this.selected.end = this.value.length - 1
108 | }
109 | } else {
110 | for (let i = 1; i < this.value.length; i++) {
111 | let width = this.context.measureText(this.value.slice(0, i)).width
112 | if (Math.abs(this.startPosition + width - event.clientX) < 7) {
113 | this.selected.end = i
114 | break
115 | }
116 | }
117 | }
118 | }
119 | })
120 | this.mouseup(() => {
121 | this.selecting = false
122 | })
123 | document.addEventListener('click', (event) => {
124 | if (!(event.clientX >= this.layout.left &&
125 | event.clientX <= this.layout.right &&
126 | event.clientY >= this.layout.top &&
127 | event.clientY <= this.layout.bottom))
128 | {
129 | this.focus = false
130 | this.borderColor = '#dcdfe6'
131 | this.selected = { start: -1, end: -1 }
132 | }
133 | })
134 | document.addEventListener('keydown', (event) => {
135 | if (this.focus) {
136 | let key = event.key
137 | if (key === 'Backspace') {
138 | // 必须用嵌套的写法, 如果是逻辑与运算的话, 后面还有可能满足外层 if
139 | if (this.value !== '') {
140 | if (this.selected.start !== -1 && this.selected.end !== -1) {
141 | let start = Math.min(this.selected.start, this.selected.end)
142 | let end = Math.max(this.selected.start, this.selected.end)
143 | this.array.splice(start, end - start + 1)
144 | this.index = start - 1
145 | this.selected = { start: -1, end: -1}
146 | } else {
147 | this.array.splice(this.index, 1)
148 | this.index -= 1
149 | }
150 | }
151 | } else if (key === 'ArrowLeft') {
152 | if (this.index > -1) {
153 | this.index -= 1
154 | }
155 | } else if (key === 'ArrowRight') {
156 | if (this.index < this.array.length - 1) {
157 | this.index += 1
158 | }
159 | } else if (event.metaKey || event.ctrlKey) {
160 | if (key === 'c') {
161 | navigator.clipboard.writeText(this.selectedValue)
162 | } else if (key === 'v') {
163 | navigator.clipboard.readText().then((text) => {
164 | if (this.selected.start !== -1 && this.selected.end !== -1) {
165 | let start = Math.min(this.selected.start, this.selected.end)
166 | let deleteLength = Math.abs(this.selected.start, this.selected.end)
167 | // 检查所粘贴内容是否超出输入框
168 | let temp = this.array.slice(0)
169 | temp.splice(start, deleteLength, ...text.split(''))
170 | temp = temp.join('')
171 | this.context.font = '14px sans-serif'
172 | let width = this.context.measureText(temp).width
173 | if (width >= parseInt(this.style['width'].value) - 30) {
174 | return
175 | }
176 | this.array.splice(start, deleteLength, ...text.split(''))
177 | this.selected = { start: -1, end: -1 }
178 | this.index -= deleteLength
179 | this.index += text.length
180 | this.vm[this.bind] = this.value
181 | } else {
182 | // 检查所粘贴内容是否超出输入框
183 | let temp = this.array.slice(0)
184 | temp.splice(this.index + 1, 0, ...text.split(''))
185 | temp = temp.join('')
186 | this.context.font = '14px sans-serif'
187 | let width = this.context.measureText(temp).width
188 | if (width >= parseInt(this.style['width'].value) - 30) {
189 | return
190 | }
191 | this.array.splice(this.index + 1, 0, ...text.split(''))
192 | this.index += text.length
193 | this.vm[this.bind] = this.value
194 | }
195 | })
196 | } else if (key === 'x') {
197 | navigator.clipboard.writeText(this.selectedValue)
198 | let start = Math.min(this.selected.start, this.selected.end)
199 | let end = Math.max(this.selected.start, this.selected.end)
200 | this.array.splice(start, end - start + 1)
201 | this.index = start - 1
202 | this.selected = { start: -1, end: -1}
203 | } else if (key === 'a') {
204 | this.selected = { start: 0, end: this.array.length - 1}
205 | }
206 | } else {
207 | if (key === 'Enter' || key === 'Shift' || key === 'Alt') {
208 | return
209 | }
210 | this.context.font = '14px sans-serif'
211 | if (this.selected.start !== -1 && this.selected.end !== -1) {
212 | let start = Math.min(this.selected.start, this.selected.end)
213 | let end = Math.max(this.selected.start, this.selected.end)
214 | this.array.splice(start, end - start + 1, key)
215 | this.index = start
216 | this.selected = { start: -1, end: -1}
217 | } else {
218 | let width = this.context.measureText(this.value).width
219 | if (width >= parseInt(this.style['width'].value) - 30) {
220 | return
221 | }
222 | this.index += 1
223 | this.array.splice(this.index, 0, key)
224 | }
225 | }
226 | this.vm[this.bind] = this.value
227 | }
228 | })
229 | }
230 |
231 | caretBlink() {
232 | setInterval(() => {
233 | if (this.focus) {
234 | if (this.caretColor === 'black') {
235 | this.caretColor = 'white'
236 | } else {
237 | this.caretColor = 'black'
238 | }
239 | }
240 | }, 700)
241 | }
242 |
243 | draw() {
244 | pen.reset()
245 | this.array = this.props.value.split('')
246 | // 边框
247 | let width = parseInt(this.style['width'].value)
248 | let height = parseInt(this.style['height'].value)
249 | pen.drawRect(this.layout.left, this.layout.top, width, height, 4)
250 | pen.stroke(this.borderColor)
251 | // 光标
252 | if (this.focus) {
253 | let x = this.caretPosition
254 | let startY = this.layout.top + 12
255 | let endY = this.layout.bottom - 12
256 | pen.drawLine(x, startY, x, endY, this.caretColor)
257 | }
258 | // 选中状态
259 | if (this.selected.start !== -1 && this.selected.end !== -1) {
260 | let start = Math.min(this.selected.start, this.selected.end)
261 | let end = Math.max(this.selected.start, this.selected.end)
262 | start = this.startPosition + this.context.measureText(this.value.slice(0, start)).width
263 | end = this.startPosition + this.context.measureText(this.value.slice(0, end + 1)).width
264 | pen.drawRect(start, this.layout.top, end - start, 38, 0)
265 | pen.fill('#b2d2fd')
266 | }
267 | // 输入的文字本身
268 | pen.drawText(this.value, this.startPosition, this.layout.top + 12, 14, '#606266')
269 | // hint
270 | if (this.array.length === 0) {
271 | if (this.props.hint) {
272 | pen.drawText(this.props.hint, this.startPosition, this.layout.top + 12, 14, '#dcdfe6')
273 | }
274 | }
275 | }
276 | }
277 |
--------------------------------------------------------------------------------
/src/runtime/component/radio-component.js:
--------------------------------------------------------------------------------
1 | class RadioComponent extends Component {
2 | constructor(template, context) {
3 | super(template, context)
4 | this.firstDraw = true
5 | this.checked = false
6 | this.borderColor = '#dcdfe6'
7 | this.registerEvent()
8 | }
9 |
10 | static group = {}
11 |
12 | setBox() {
13 | this.context.font = '14px sans-serif'
14 | let labelWidth = this.context.measureText(this.props.label).width
15 | this.style['width'] = {
16 | value: `${26 + labelWidth}px`,
17 | }
18 | this.style['height'] = {
19 | value: '16px',
20 | }
21 | }
22 |
23 | registerEvent() {
24 | this.tap(() => {
25 | for (let item of RadioComponent.group[this.bind]) {
26 | item.checked = false
27 | }
28 | this.checked = true
29 | this.vm[this.bind] = this.props.option
30 | })
31 | this.hover(() => {
32 | this.borderColor = '#409eff'
33 | document.body.style.cursor = 'pointer'
34 | }, () => {
35 | this.borderColor = '#dcdfe6'
36 | })
37 | }
38 |
39 | draw() {
40 | pen.reset()
41 | if (this.firstDraw) {
42 | this.firstDraw = false
43 | if (this.props.value === this.props.option) {
44 | this.checked = true
45 | }
46 | if (RadioComponent.group[this.bind]) {
47 | RadioComponent.group[this.bind].push(this)
48 | } else {
49 | RadioComponent.group[this.bind] = [this]
50 | }
51 | }
52 | // 选择框
53 | let x = this.layout.left + 8
54 | let y = this.layout.top + 8
55 | pen.drawCircle(x, y, 8)
56 | pen.fill('white')
57 | pen.stroke(this.borderColor)
58 | if (this.checked) {
59 | pen.drawCircle(x, y, 5)
60 | pen.stroke('#409eff', 5)
61 | }
62 | // 文字
63 | pen.drawText(this.props.label, this.layout.left + 26, this.layout.top + 1, 14, '#606266')
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/src/runtime/component/select-component.js:
--------------------------------------------------------------------------------
1 | class SelectComponent extends Component {
2 | constructor(template, context) {
3 | super(template, context)
4 | this.firstDraw = true
5 | this.options = []
6 | this.selected = ''
7 | this.show = false
8 | this.optionPositions = []
9 | this.borderColor = '#dcdfe6'
10 | this.registerEvent()
11 | }
12 |
13 | setBox() {
14 | this.style['width'] = {
15 | value: '240px',
16 | }
17 | this.style['height'] = {
18 | value: '40px',
19 | }
20 | }
21 |
22 | registerEvent() {
23 | this.hover(() => {
24 | if (!this.show) {
25 | this.borderColor = '#c0c4cc'
26 | }
27 | }, () => {
28 | if (!this.show) {
29 | this.borderColor = '#dcdfe6'
30 | }
31 | })
32 | this.tap(() => {
33 | this.borderColor = '#409eff'
34 | this.show = !this.show
35 | })
36 | this.hover(() => {
37 | document.body.style.cursor = 'pointer'
38 | })
39 | document.addEventListener('mousemove', (event) => {
40 | if (this.show) {
41 | for (let item of this.optionPositions) {
42 | if (event.clientX >= this.layout.left &&
43 | event.clientX <= this.layout.right &&
44 | event.clientY >= this.layout.bottom + item.top &&
45 | event.clientY <= this.layout.bottom + item.bottom)
46 | {
47 | item.selected = true
48 | document.body.style.cursor = 'pointer'
49 | } else {
50 | item.selected = false
51 | }
52 | }
53 | }
54 | })
55 | document.addEventListener('click', (event) => {
56 | if (this.show) {
57 | for (let i = 0; i < this.optionPositions.length; i++) {
58 | let item = this.optionPositions[i]
59 | if (event.clientX >= this.layout.left &&
60 | event.clientX <= this.layout.right &&
61 | event.clientY >= this.layout.bottom + item.top &&
62 | event.clientY <= this.layout.bottom + item.bottom)
63 | {
64 | this.selected = this.options[i]
65 | this.vm[this.bind] = this.selected
66 | this.show = false
67 | this.borderColor = '#dcdfe6'
68 | }
69 | }
70 | }
71 | })
72 | }
73 |
74 | draw() {
75 | pen.reset()
76 | if (this.firstDraw) {
77 | this.firstDraw = false
78 | for (let item of this.props.options) {
79 | if (item === this.props.value) {
80 | this.selected = item
81 | }
82 | }
83 | this.options = this.props.options
84 | for (let i = 0; i < this.options.length; i++) {
85 | let top = 12 + i * 36 + 6
86 | let bottom = top + 36
87 | this.optionPositions.push({
88 | top: top,
89 | bottom: bottom,
90 | selected: false,
91 | })
92 | }
93 | }
94 | // 边框
95 | pen.drawRect(this.layout.left, this.layout.top, 240, 40, 4)
96 | pen.stroke(this.borderColor)
97 | // 箭头
98 | pen.drawText('▽', this.layout.right - 30, this.layout.top + 12, 14, '#c0c4cc')
99 | if (this.show) {
100 | // 菜单背景
101 | let width = parseInt(this.style['width'].value)
102 | let height = this.options.length * 36 + 15
103 | pen.drawRect(this.layout.left, this.layout.bottom + 12, width, height, 4)
104 | pen.stroke('#e4e7ed')
105 | pen.fill('white')
106 | pen.drawText('△', this.layout.left + 35, this.layout.bottom + 2, 12, '#e4e7ed')
107 | // 菜单内容
108 | for (let i = 0; i < this.options.length; i++) {
109 | let item = this.optionPositions[i]
110 | if (item.selected) {
111 | pen.drawRect(this.layout.left, this.layout.bottom + item.top, 240, 40, 0)
112 | pen.fill('#f5f7fa')
113 | }
114 | pen.drawText(this.options[i], this.layout.left + 20, this.layout.bottom + item.top + 10, 16, '#606266')
115 | }
116 | }
117 | if (this.selected) {
118 | // 选定内容
119 | pen.drawText(this.selected, this.layout.left + 16, this.layout.top + 11, 16, '#606266')
120 | } else {
121 | // 提示文字
122 | pen.drawText('Select', this.layout.left + 16, this.layout.top + 11, 16, '#b9bcc5')
123 | }
124 | }
125 | }
126 |
--------------------------------------------------------------------------------
/src/runtime/component/slider-component.js:
--------------------------------------------------------------------------------
1 | class SliderComponent extends Component {
2 | constructor(template, context) {
3 | super(template, context)
4 | this.firstDraw = true,
5 | this.value = 0
6 | this.block = {
7 | mousedown: false,
8 | left: 0,
9 | right: 0,
10 | top: 0,
11 | bottom: 0,
12 | offsetX: 0,
13 | }
14 | this.registerEvent()
15 | }
16 |
17 | setBox() {
18 | this.style['width'] = this.style['width'] ?? {
19 | value: '300px',
20 | }
21 | this.style['height'] = {
22 | value: '20px',
23 | }
24 | }
25 |
26 | drag() {
27 | document.addEventListener('panstart', (event) => {
28 | if (event.detail.startX >= this.block.left &&
29 | event.detail.startX <= this.block.right &&
30 | event.detail.startY >= this.block.top &&
31 | event.detail.startY <= this.block.bottom)
32 | {
33 | this.block.mousedown = true
34 | this.block.offsetX = event.detail.startX - this.block.left
35 | document.body.style.cursor = 'grabbing'
36 | }
37 | })
38 | document.addEventListener('panmove', (event) => {
39 | if (this.block.mousedown){
40 | let left = event.detail.clientX - this.block.offsetX
41 | if (left < this.layout.left) {
42 | left = this.layout.left
43 | } else if (left > this.layout.right - 20) {
44 | left = this.layout.right - 20
45 | }
46 | this.block.left = left
47 | this.block.right = this.block.left + 20
48 | this.value = Math.floor((this.block.left - this.layout.left) / (this.layout.right - this.layout.left - 20) * 100)
49 | this.vm[this.bind] = this.value
50 | document.body.style.cursor = 'grabbing'
51 | }
52 | })
53 | document.addEventListener('panend', (event) => {
54 | this.block.mousedown = false
55 | document.body.style.cursor = 'grab'
56 | })
57 | }
58 |
59 | registerEvent() {
60 | this.drag()
61 | document.addEventListener('mousemove', (event) => {
62 | if (event.clientX >= this.block.left &&
63 | event.clientX <= this.block.right &&
64 | event.clientY >= this.block.top &&
65 | event.clientY <= this.block.bottom)
66 | {
67 | document.body.style.cursor = 'grab'
68 | }
69 | })
70 | }
71 |
72 | draw() {
73 | pen.reset()
74 | if (this.firstDraw) {
75 | this.firstDraw = false
76 | if (this.props.value) {
77 | this.value = Number(this.props.value)
78 | }
79 | }
80 | // 滑条
81 | let width = parseInt(this.style['width'].value) - 20
82 | let height = 6
83 | pen.drawRect(this.layout.left + 10, this.layout.top + 7, width, height, 3)
84 | pen.fill('#e4e7ed')
85 | // 滑块
86 | this.block.left = this.layout.left + (this.layout.right - this.layout.left - 20) / 100 * this.value
87 | this.block.right = this.block.left + 20
88 | this.block.top = this.layout.top
89 | this.block.bottom = this.layout.bottom
90 | let x = this.block.left + 10
91 | let y = this.layout.top + 10
92 | pen.drawCircle(x, y, 10)
93 | pen.fill('white')
94 | pen.stroke('#409eff', 2)
95 | // 文字
96 | if (this.block.mousedown) {
97 | pen.drawText(this.value, x, y - 6, 12, '#409eff', 'center')
98 | }
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/src/runtime/component/switch-component.js:
--------------------------------------------------------------------------------
1 | class SwitchComponent extends Component {
2 | constructor(template, context) {
3 | super(template, context)
4 | this.firstDraw = true
5 | this.value = true
6 | this.registerEvent()
7 | }
8 |
9 | get backgroundColor() {
10 | return this.value ? '#49c45c' : '#dcdfe6'
11 | }
12 |
13 | setBox() {
14 | this.style['width'] = {
15 | value: '40px',
16 | }
17 | this.style['height'] = {
18 | value: '20px',
19 | }
20 | }
21 |
22 | registerEvent() {
23 | this.tap(() => {
24 | this.value = !this.value
25 | this.vm[this.bind] = this.value
26 | })
27 | this.hover(() => {
28 | document.body.style.cursor = 'pointer'
29 | })
30 | }
31 |
32 | draw() {
33 | pen.reset()
34 | if (this.firstDraw) {
35 | this.firstDraw = false
36 | // 因为传入的属性值为字符串类型, 而不是布尔类型, 所以需要进一步判断
37 | this.value = {
38 | 'true': true,
39 | 'false': false,
40 | }[this.props.value]
41 | }
42 | // 背景
43 | pen.drawRect(this.layout.left, this.layout.top, 40, 20, 10)
44 | pen.fill(this.backgroundColor)
45 | // 滑块
46 | let x = this.layout.right - 10
47 | let y = this.layout.top + 10
48 | if (!this.value) {
49 | x = this.layout.left + 10
50 | }
51 | pen.drawCircle(x, y, 8)
52 | pen.fill('white')
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/src/runtime/component/template-component.js:
--------------------------------------------------------------------------------
1 | class TemplateComponent extends Component {
2 | constructor(template, context) {
3 | super(template, context)
4 | }
5 |
6 | setBox() {
7 | }
8 |
9 | draw() {
10 | pen.reset()
11 | let x = this.layout.left
12 | let y = this.layout.top
13 | let width = this.layout.width
14 | let height = this.layout.height
15 | pen.drawRect(x, y, width, height, 0)
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/runtime/component/text-component.js:
--------------------------------------------------------------------------------
1 | class TextComponent extends Component {
2 | constructor(template, context) {
3 | super(template, context)
4 | }
5 |
6 | setBox() {
7 | this.context.font = `${this.style['font-size']?.value ?? '16px'} sans-serif`
8 | this.style['width'] = {
9 | value: this.context.measureText(this.props.content).width + 'px',
10 | }
11 | this.style['height'] = {
12 | value: this.style['font-size']?.value ?? '16px',
13 | }
14 | }
15 |
16 | draw() {
17 | pen.reset()
18 | let fontSize = parseInt(this.style['font-size']?.value ?? 16)
19 | let fontColor = this.style['color']?.value ?? 'black'
20 | pen.drawText(this.props.content, this.layout.left, this.layout.top, fontSize, fontColor)
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/src/runtime/construct.js:
--------------------------------------------------------------------------------
1 | function construct(template, context) {
2 | return traverse(template)
3 |
4 | function traverse(template, parent=null) {
5 | let componentObj = new ({
6 | button: ButtonComponent,
7 | checkbox: CheckboxComponent,
8 | color: ColorComponent,
9 | div: DivComponent,
10 | image: ImageComponent,
11 | input: InputComponent,
12 | radio: RadioComponent,
13 | select: SelectComponent,
14 | slider: SliderComponent,
15 | switch: SwitchComponent,
16 | template: TemplateComponent,
17 | text: TextComponent,
18 | }[template.tagName] ?? CustomComponent)(template, context)
19 | if (parent) {
20 | componentObj.parent = parent
21 | }
22 | for (let child of template.children) {
23 | componentObj.children.push(traverse(child, componentObj))
24 | }
25 | return componentObj
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/runtime/gesture.js:
--------------------------------------------------------------------------------
1 | /*
2 | 派发的事件及相应属性:
3 | 通用的
4 | start: clientX, clientY
5 | cancel: clientX, clientY
6 | 轻点(Tap)
7 | tap: clientX, clientY
8 | 长按(Press)
9 | pressstart: clientX, clientY
10 | pressend: clientX, clientY
11 | presscancel
12 | 拖动(Pan)
13 | panstart: startX, startY, clientX, clientY
14 | panmove: startX, startY, clientX, clientY
15 | panend: startX, startY, clientX, clientY, speed, isSwipe
16 | 轻扫(Swipe)
17 | swipe: startX, startY, clientX, clientY, speed
18 | */
19 | class Gesture {
20 | constructor() {
21 | this.contexts = {}
22 | if ('ontouchstart' in document) {
23 | this.listenTouch()
24 | } else {
25 | this.listenMouse()
26 | }
27 | }
28 |
29 | listenTouch() {
30 | document.addEventListener('touchstart', (event) => {
31 | for (let touch of event.changedTouches) {
32 | this.contexts[touch.identifier] = {}
33 | this.start(touch, this.contexts[touch.identifier])
34 | }
35 | })
36 | document.addEventListener('touchmove', (event) => {
37 | for (let touch of event.changedTouches) {
38 | this.move(touch, this.contexts[touch.identifier])
39 | }
40 | })
41 | document.addEventListener('touchend', (event) => {
42 | for (let touch of event.changedTouches) {
43 | this.end(touch, this.contexts[touch.identifier])
44 | delete this.contexts[touch.identifier]
45 | }
46 | })
47 | document.addEventListener('touchcancel', (event) => {
48 | for (let touch of event.changedTouches) {
49 | this.cancel(touch, this.contexts[touch.identifier])
50 | delete this.contexts[touch.identifier]
51 | }
52 | })
53 | }
54 |
55 | listenMouse() {
56 | document.addEventListener('mousedown', (event) => {
57 | this.contexts['mouse'] = {}
58 | this.start(event, this.contexts['mouse'])
59 | let mousemove = (event) => {
60 | this.move(event, this.contexts['mouse'])
61 | }
62 | let mouseup = (event) => {
63 | this.end(event, this.contexts['mouse'])
64 | document.removeEventListener('mousemove', mousemove)
65 | document.removeEventListener('mouseup', mouseup)
66 | }
67 | document.addEventListener('mousemove', mousemove)
68 | document.addEventListener('mouseup', mouseup)
69 | })
70 | }
71 |
72 | start(point, context) {
73 | document.dispatchEvent(new CustomEvent('start', {
74 | detail: {
75 | clientX: point.clientX,
76 | clientY: point.clientY,
77 | }
78 | }))
79 | context.startX = point.clientX
80 | context.startY = point.clientY
81 | context.moves = []
82 | context.action = 'tap'
83 | context.timeoutHandler = setTimeout(() => {
84 | if (context.action === 'pan') {
85 | return
86 | }
87 | context.action = 'press'
88 | document.dispatchEvent(new CustomEvent('pressstart', {
89 | detail: {
90 | clientX: point.clientX,
91 | clientY: point.clientY,
92 | }
93 | }))
94 | }, 500)
95 | }
96 |
97 | move(point, context) {
98 | let offsetX = point.clientX - context.startX
99 | let offsetY = point.clientY - context.startY
100 | if (context.action !== 'pan' && offsetX ** 2 + offsetY ** 2 > 100) {
101 | if (context.action === 'press') {
102 | document.dispatchEvent(new CustomEvent('presscancel'))
103 | }
104 | context.action = 'pan'
105 | document.dispatchEvent(new CustomEvent('panstart', {
106 | detail: {
107 | startX: context.startX,
108 | startY: context.startY,
109 | clientX: point.clientX,
110 | clientY: point.clientY,
111 | }
112 | }))
113 | }
114 | if (context.action === 'pan') {
115 | context.moves.push({
116 | clientX: point.clientX,
117 | clientY: point.clientY,
118 | time: Date.now(),
119 | })
120 | context.moves = context.moves.filter(move => Date.now() - move.time < 300)
121 | document.dispatchEvent(new CustomEvent('panmove', {
122 | detail: {
123 | startX: context.startX,
124 | startY: context.startY,
125 | clientX: point.clientX,
126 | clientY: point.clientY,
127 | }
128 | }))
129 | }
130 | }
131 |
132 | end(point, context) {
133 | clearTimeout(context.timeoutHandler)
134 | if (context.action === 'tap') {
135 | document.dispatchEvent(new CustomEvent('tap', {
136 | detail: {
137 | clientX: point.clientX,
138 | clientY: point.clientY,
139 | }
140 | }))
141 | } else if (context.action === 'press') {
142 | document.dispatchEvent(new CustomEvent('pressend', {
143 | detail: {
144 | clientX: point.clientX,
145 | clientY: point.clientY,
146 | }
147 | }))
148 | } else if (context.action === 'pan') {
149 | let move = context.moves[0]
150 | let speed = Math.sqrt((point.clientX - move.clientX) ** 2 + (point.clientY - move.clientY) ** 2) / (Date.now() - move.time)
151 | let isSwipe = speed > 1.5
152 | document.dispatchEvent(new CustomEvent('panend', {
153 | detail: {
154 | startX: context.startX,
155 | startY: context.startY,
156 | clientX: point.clientX,
157 | clientY: point.clientY,
158 | speed: speed,
159 | isSwipe: isSwipe,
160 | }
161 | }))
162 | if (isSwipe) {
163 | document.dispatchEvent(new CustomEvent('swipe', {
164 | detail: {
165 | startX: context.startX,
166 | startY: context.startY,
167 | clientX: point.clientX,
168 | clientY: point.clientY,
169 | speed: speed,
170 | }
171 | }))
172 | }
173 | }
174 | }
175 |
176 | cancel(point, context) {
177 | clearTimeout(context.timeoutHandler)
178 | document.dispatchEvent(new CustomEvent('cancel', {
179 | detail: {
180 | clientX: point.clientX,
181 | clientY: point.clientY,
182 | }
183 | }))
184 | }
185 | }
186 |
187 |
188 | new Gesture()
189 |
--------------------------------------------------------------------------------
/src/runtime/layout.js:
--------------------------------------------------------------------------------
1 | function layout(component, root) {
2 | // root 用于标记是从根节点开始重排, 还是在排版过程中子组件在递归调用 layout()
3 | let mainSize = '' // 'width' | 'height'
4 | let mainStart = '' // 'left' | 'right' | 'top' | 'bottom'
5 | let mainEnd = '' // 'left' | 'right' | 'top' | 'bottom'
6 | let mainSign = 0 // +1 | -1
7 | let mainBase = 0 // 0 | style.width | style.height
8 | let crossSize = '' // 'width' | 'height'
9 | let crossStart = '' // 'left' | 'right' | 'top' | 'bottom'
10 | let crossEnd = '' // 'left' | 'right' | 'top' | 'bottom'
11 | let crossSign = 0 // +1 | -1
12 | let crossBase = 0 // 0 | style.width | style.height
13 | let children = []
14 | let flexLines = []
15 |
16 | main()
17 |
18 | function main() {
19 | if (component.children.length === 0) {
20 | return
21 | }
22 | setup(component) // 设置组件的 layout.width 和 layout.height (即整个盒模型最外层的宽高)
23 | setDefaultValue() // 对于没有显式设置的 flex 相关的属性, 设置默认值
24 | setRuler() // 根据 flex-direction 设置相应的尺度
25 | setChildren() // 设置子组件的 layout.width 和 layout.height, 并按 order 排序
26 | splitLine() // 分行(准确地说, 应该是分主轴)
27 | computeMainAxis() // 计算主轴
28 | computeCrossAxis() // 计算交叉轴
29 | for (let child of component.children) {
30 | layout(child)
31 | }
32 | }
33 |
34 | function setup(component) {
35 | // 因为默认为 border-box, 所以 width 包含 border 和 padding
36 | if (root) {
37 | // 从根组件重排的情况
38 | component.layout.width = parseInt(component.style.width?.value ?? 0) + parseInt(component.style.margin?.value ?? 0) * 2
39 | component.layout.height = parseInt(component.style.height?.value ?? 0) + parseInt(component.style.margin?.value ?? 0) * 2
40 | } else {
41 | // 递归调用的情况
42 | let width = parseInt(component.style.width?.value ?? 0) + parseInt(component.style.margin?.value ?? 0) * 2
43 | let height = parseInt(component.style.height?.value ?? 0) + parseInt(component.style.margin?.value ?? 0) * 2
44 | // 当 width, height 为 0 时, 取之前已经计算的 layout 的宽高 (layout 的宽高可能是因为组件具有 flex 属性而被 computeFlexLine 计算出来的)
45 | // 当 width, height 不为 0 时, 可能是显式设定的固定值, 也可能是 props 的数据改变进而改变了 style
46 | if (width === 0 && height === 0) {
47 | width = component.layout.width ?? 0
48 | height = component.layout.height ?? 0
49 | }
50 | component.layout.width = width
51 | component.layout.height = height
52 | }
53 | }
54 |
55 | function setDefaultValue() {
56 | let style = component.style
57 | style['justify-content'] = style['justify-content'] ?? { value: 'flex-start' }
58 | style['align-items'] = style['align-items'] ?? { value: 'stretch' }
59 | style['flex-direction'] = style['flex-direction'] ?? { value: 'row' }
60 | style['flex-wrap'] = style['flex-wrap'] ?? { value: 'nowrap' }
61 | style['align-content'] = style['align-content'] ?? { value: 'stretch' }
62 | if (style['flex-flow']) {
63 | style['flex-direction'] = { value: style['flex-flow'].value.split(' ')[0] }
64 | style['flex-wrap'] = { value: style['flex-flow'].value.split(' ')[1] }
65 | }
66 | }
67 |
68 | function setRuler() {
69 | let style = component.style
70 | let layout = component.layout
71 | if (style['flex-direction'].value === 'row') {
72 | mainSize = 'width'
73 | mainStart = 'left'
74 | mainEnd = 'right'
75 | mainSign = +1
76 | mainBase = (layout.left ?? 0) + parseInt(style.margin?.value ?? 0) + parseInt(style.border?.value.split(' ')[0] ?? 0) + parseInt(style.padding?.value ?? 0)
77 |
78 | crossSize = 'height'
79 | crossStart = 'top'
80 | crossEnd = 'bottom'
81 | crossSign = +1
82 | crossBase = (layout.top ?? 0) + parseInt(style.margin?.value ?? 0) + parseInt(style.border?.value.split(' ')[0] ?? 0) + parseInt(style.padding?.value ?? 0)
83 | } else if (style['flex-direction'].value === 'row-reverse') {
84 | mainSize = 'width'
85 | mainStart = 'right'
86 | mainEnd = 'left'
87 | mainSign = -1
88 | mainBase = (layout.right ?? layout.width) - parseInt(style.margin?.value ?? 0) - parseInt(style.border?.value.split(' ')[0] ?? 0) - parseInt(style.padding?.value ?? 0)
89 |
90 | crossSize = 'height'
91 | crossStart = 'top'
92 | crossEnd = 'bottom'
93 | crossSign = +1
94 | crossBase = (layout.top ?? 0) + parseInt(style.margin?.value ?? 0) + parseInt(style.border?.value.split(' ')[0] ?? 0) + parseInt(style.padding?.value ?? 0)
95 | } else if (style['flex-direction'].value === 'column') {
96 | mainSize = 'height'
97 | mainStart = 'top'
98 | mainEnd = 'bottom'
99 | mainSign = +1
100 | mainBase = (layout.top ?? 0) + parseInt(style.margin?.value ?? 0) + parseInt(style.border?.value.split(' ')[0] ?? 0) + parseInt(style.padding?.value ?? 0)
101 |
102 | crossSize = 'width'
103 | crossStart = 'left'
104 | crossEnd = 'right'
105 | crossSign = +1
106 | crossBase = (layout.left ?? 0) + parseInt(style.margin?.value ?? 0) + parseInt(style.border?.value.split(' ')[0] ?? 0) + parseInt(style.padding?.value ?? 0)
107 | } else if (style['flex-direction'].value === 'column-reverse') {
108 | mainSize = 'height'
109 | mainStart = 'bottom'
110 | mainEnd = 'top'
111 | mainSign = -1
112 | mainBase = (layout.bottom ?? layout.height) - parseInt(style.margin?.value ?? 0) - parseInt(style.border?.value.split(' ')[0] ?? 0) - parseInt(style.padding?.value ?? 0)
113 |
114 | crossSize = 'width'
115 | crossStart = 'left'
116 | crossEnd = 'right'
117 | crossSign = +1
118 | crossBase = (layout.left ?? 0) + parseInt(style.margin?.value ?? 0) + parseInt(style.border?.value.split(' ')[0] ?? 0) + parseInt(style.padding?.value ?? 0)
119 | }
120 |
121 | if (style['flex-wrap'].value === 'wrap-reverse') {
122 | [crossStart, crossEnd] = [crossEnd, crossStart]
123 | crossSign = -1
124 | crossBase = {
125 | 'row': (layout.bottom ?? layout.height) - parseInt(style.margin?.value ?? 0) - parseInt(style.border?.value.split(' ')[0] ?? 0) - parseInt(style.padding?.value ?? 0),
126 | 'row-reverse': (layout.bottom ?? layout.height) - parseInt(style.margin?.value ?? 0) - parseInt(style.border?.value.split(' ')[0] ?? 0) - parseInt(style.padding?.value ?? 0),
127 | 'column': (layout.right ?? layout.width) - parseInt(style.margin?.value ?? 0) - parseInt(style.border?.value.split(' ')[0] ?? 0) - parseInt(style.padding?.value ?? 0),
128 | 'column-reverse': (layout.right ?? layout.width) - parseInt(style.margin?.value ?? 0) - parseInt(style.border?.value.split(' ')[0] ?? 0) - parseInt(style.padding?.value ?? 0),
129 | }[style['flex-direction'].value]
130 | }
131 | }
132 |
133 | function setChildren() {
134 | for (let child of component.children) {
135 | setup(child)
136 | children.push(child)
137 | }
138 | children.sort((a, b) => {
139 | return (a.style.order?.value ?? 0) - (b.style.order?.value ?? 0)
140 | })
141 | }
142 |
143 | function createLine() {
144 | let newLine = []
145 | let margin = parseInt(component.style.margin?.value ?? 0)
146 | let border = parseInt(component.style.border?.value.split(' ')[0] ?? 0)
147 | let padding = parseInt(component.style.padding?.value ?? 0)
148 | newLine.mainSpace = component.layout[mainSize] - margin * 2 - border * 2 - padding * 2
149 | newLine.crossSpace = 0
150 | flexLines.push(newLine)
151 | return newLine
152 | }
153 |
154 | function splitLine() {
155 | let newLine = createLine()
156 | let style = component.style
157 | let layout = component.layout
158 | for (let child of children) {
159 | let childStyle = child.style
160 | let childLayout = child.layout
161 | if (childStyle['flex']) {
162 | // flex 属性意味着可伸缩, 无论剩余多少尺寸都能放进去
163 | newLine.push(child)
164 | newLine.crossSpace = Math.max(newLine.crossSpace, childLayout[crossSize])
165 | } else if (style['flex-wrap'].value === 'nowrap') {
166 | // 强行在一行中塞入全部元素
167 | newLine.push(child)
168 | newLine.mainSpace -= childLayout[mainSize]
169 | newLine.crossSpace = Math.max(newLine.crossSpace, childLayout[crossSize])
170 | } else {
171 | // 如果元素超过容器, 则压缩到容器大小
172 | let containerWidth = layout[mainSize] - parseInt(style.margin?.value ?? 0) * 2 - parseInt(style.border?.value.split(' ')[0] ?? 0) * 2 - parseInt(style.padding?.value ?? 0) * 2
173 | childLayout[mainSize] = Math.min(childLayout[mainSize], containerWidth)
174 | // 分行
175 | if (newLine.mainSpace < childLayout[mainSize]) {
176 | newLine = createLine()
177 | }
178 | // 将元素收入行内
179 | newLine.push(child)
180 | newLine.mainSpace -= childLayout[mainSize]
181 | newLine.crossSpace = Math.max(newLine.crossSpace, childLayout[crossSize])
182 | }
183 | }
184 | }
185 |
186 | function computeFlexLine(line, flexTotal) {
187 | let currentMain = mainBase
188 | for (let child of line) {
189 | if (child.style['flex']) {
190 | child.layout[mainSize] = parseInt(child.style['flex'].value) / flexTotal * line.mainSpace
191 | }
192 | child.layout[mainStart] = currentMain
193 | child.layout[mainEnd] = currentMain + mainSign * child.layout[mainSize]
194 | currentMain = child.layout[mainEnd]
195 | }
196 | }
197 |
198 | function computeNotFlexLine(line) {
199 | let style = component.style
200 | let currentMain = mainBase
201 | let space = 0
202 | if (style['justify-content'].value === 'flex-start') {
203 | currentMain = mainBase
204 | space = 0
205 | } else if (style['justify-content'].value === 'flex-end') {
206 | currentMain = mainBase + mainSign * line.mainSpace
207 | space = 0
208 | } else if (style['justify-content'].value === 'center') {
209 | currentMain = mainBase + mainSign * line.mainSpace / 2
210 | space = 0
211 | } else if (style['justify-content'].value === 'space-between') {
212 | currentMain = mainBase
213 | space = mainSign * line.mainSpace / (line.length - 1)
214 | } else if (style['justify-content'].value === 'space-around') {
215 | currentMain = mainBase + mainSign * line.mainSpace / line.length / 2
216 | space = mainSign * line.mainSpace / line.length
217 | }
218 | for (let child of line) {
219 | let childLayout = child.layout
220 | childLayout[mainStart] = currentMain
221 | childLayout[mainEnd] = currentMain + mainSign * childLayout[mainSize]
222 | currentMain = childLayout[mainEnd] + space
223 | }
224 | }
225 |
226 | function computeNegativeSpaceLine(line) {
227 | let layout = component.layout
228 | let scale = layout[mainSize] / (layout[mainSize] + (-line.mainSpace))
229 | let currentMain = mainBase
230 | for (let child of line) {
231 | let childLayout = child.layout
232 | if (child.style['flex']) {
233 | // 将有 flex 属性的元素压缩到 0
234 | childLayout[mainSize] = 0
235 | }
236 | childLayout[mainSize] *= scale
237 | childLayout[mainStart] = currentMain
238 | childLayout[mainEnd] = currentMain + mainSign * childLayout[mainSize]
239 | currentMain = childLayout[mainEnd]
240 | }
241 | }
242 |
243 | function computeMainAxis() {
244 | for (let line of flexLines) {
245 | if (line.mainSpace >= 0) {
246 | let flexTotal = 0
247 | for (let child of line) {
248 | flexTotal += parseInt(child.style['flex']?.value ?? 0)
249 | }
250 | if (flexTotal > 0) {
251 | // 含有 [有 flex 属性的元素] 的行
252 | computeFlexLine(line, flexTotal)
253 | } else {
254 | // 没有 [有 flex 属性的元素] 的行
255 | computeNotFlexLine(line)
256 | }
257 | } else {
258 | // 剩余空间为负, 说明 [flex-wrap: nowrap], 等比压缩不含有 flex 元素的属性
259 | computeNegativeSpaceLine(line)
260 | }
261 | }
262 | }
263 |
264 | function computeCrossAxis() {
265 | // 根据 align-content align-items align-self 确定元素位置
266 | let style = component.style
267 | let layout = component.layout
268 | // 如果交叉轴没有设置, 则自动撑开交叉轴
269 | if (layout[crossSize] === 0) {
270 | for (let line of flexLines) {
271 | layout[crossSize] += line.crossSpace
272 | }
273 | layout[crossSize] += parseInt(style.padding?.value ?? 0) * 2 + parseInt(style.border?.value.split(' ')[0] ?? 0) * 2 + parseInt(style.margin?.value ?? 0) * 2
274 | }
275 | // 计算交叉轴总空白
276 | let crossSpaceTotal = layout[crossSize] - parseInt(style.margin?.value ?? 0) * 2 - parseInt(style.border?.value.split(' ')[0] ?? 0) * 2 - parseInt(style.padding?.value ?? 0) * 2
277 | for (let line of flexLines) {
278 | crossSpaceTotal -= line.crossSpace
279 | }
280 | // 确定每一条主轴位于整个容器的交叉轴的位置
281 | let currentCross = crossBase
282 | let space = 0
283 | if (style['align-content'].value === 'flex-start') {
284 | currentCross = crossBase
285 | space = 0
286 | } else if (style['align-content'].value === 'flex-end') {
287 | currentCross = crossBase + crossSign * crossSpaceTotal
288 | space = 0
289 | } else if (style['align-content'].value === 'center') {
290 | currentCross = crossBase + crossSign * crossSpaceTotal / 2
291 | space = 0
292 | } else if (style['align-content'].value === 'space-between') {
293 | currentCross = crossBase
294 | space = crossSign * crossSpaceTotal / (flexLines.length - 1)
295 | } else if (style['align-content'].value === 'space-around') {
296 | currentCross = crossBase + crossSign * crossSpaceTotal / flexLines.length / 2
297 | space = crossSign * crossSpaceTotal / flexLines.length
298 | } else if (style['align-content'].value === 'stretch') {
299 | currentCross = crossBase
300 | space = 0
301 | }
302 | // 确定每个元素的具体位置
303 | for (let line of flexLines) {
304 | let lineCrossSize = line.crossSpace
305 | if (style['align-content'].value === 'stretch') {
306 | // 平分剩余的空白空间, 拉伸填满
307 | lineCrossSize = line.crossSpace + crossSpaceTotal / flexLines.length
308 | }
309 | for (let child of line) {
310 | let childLayout = child.layout
311 | let align = child.style['align-self']?.value || style['align-items'].value
312 | if (align === 'stretch') {
313 | childLayout[crossStart] = currentCross
314 | childLayout[crossSize] = childLayout[crossSize] || lineCrossSize
315 | childLayout[crossEnd] = childLayout[crossStart] + crossSign * childLayout[crossSize]
316 | } else if (align === 'flex-start') {
317 | childLayout[crossStart] = currentCross
318 | childLayout[crossEnd] = childLayout[crossStart] + crossSign * childLayout[crossSize]
319 | } else if (align === 'flex-end') {
320 | childLayout[crossStart] = currentCross + crossSign * lineCrossSize - crossSign * childLayout[crossSize]
321 | childLayout[crossEnd] = childLayout[crossStart] + crossSign * childLayout[crossSize]
322 | } else if (align === 'center') {
323 | childLayout[crossStart] = currentCross + crossSign * (lineCrossSize - childLayout[crossSize]) / 2
324 | childLayout[crossEnd] = childLayout[crossStart] + crossSign * childLayout[crossSize]
325 | }
326 | }
327 | currentCross += crossSign * lineCrossSize + space
328 | }
329 | }
330 | }
331 |
--------------------------------------------------------------------------------
/src/runtime/load-image.js:
--------------------------------------------------------------------------------
1 | function loadImage(component) {
2 | if (component instanceof ImageComponent) {
3 | component.image = new Image()
4 | component.image.src = component.props.path
5 | }
6 | for (let child of component.children) {
7 | loadImage(child)
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/src/runtime/main.js:
--------------------------------------------------------------------------------
1 | const components = {}
2 |
3 | function main() {
4 | // 创建 Canvas, 获取 context, pen 设置 context
5 | let canvas = new Canvas()
6 | pen.setContext(canvas.context)
7 |
8 | // 遍历 componentJson, 转换为若干 Component 对象, 并挂在 components 上
9 | for (let [key, value] of Object.entries(componentJson)) {
10 | let { style, template, script, error } = value
11 | if (error) {
12 | document.body.innerHTML = `${error}
`
13 | return
14 | }
15 | let component = construct(template, canvas.context)
16 | bindData(component, style, script)
17 | components[key] = component
18 | }
19 |
20 | // 根组件
21 | let rootComponent = components.main
22 |
23 | // 挂载子组件
24 | mountChildren(rootComponent)
25 |
26 | // 如果 dpr 不为 1, 需要缩放 canvas. 但其 style 的宽高始终和视口是一致的.
27 | rootComponent.style['width'] = { value: canvas.canvas.style.width }
28 | rootComponent.style['height'] = { value: canvas.canvas.style.height }
29 |
30 | // 载入图片, 排版, 渲染
31 | loadImage(rootComponent)
32 | layout(rootComponent)
33 | canvas.launch(rootComponent)
34 | }
35 |
--------------------------------------------------------------------------------
/src/runtime/mount-children.js:
--------------------------------------------------------------------------------
1 | function mountChildren(component) {
2 | for (let i = 0; i < component.children.length; i++) {
3 | if (component.children[i] instanceof CustomComponent) {
4 | component.children[i] = component.children[i].mount()
5 | }
6 | mountChildren(component.children[i])
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/src/runtime/pen.js:
--------------------------------------------------------------------------------
1 | const pen = {
2 | context: null,
3 | setContext(context) {
4 | this.context = context
5 | },
6 | reset() {
7 | // 常用属性
8 | this.context.strokeStyle = 'black'
9 | this.context.fillStyle = 'white'
10 | this.context.lineWidth = 1
11 | this.context.lineCap = 'butt'
12 | this.context.setLineDash([])
13 | // 文字相关
14 | this.context.font = '14px sans-serif'
15 | this.context.textAlign = 'left'
16 | this.context.textBaseline = 'top'
17 | // 阴影相关
18 | this.context.shadowOffsetX = 0
19 | this.context.shadowOffsetY = 0
20 | this.context.shadowBlur = 0
21 | this.context.shadowColor = 'black'
22 | },
23 | stroke(color, lineWidth=1) {
24 | this.context.strokeStyle = color
25 | this.context.lineWidth = lineWidth
26 | this.context.stroke()
27 | this.context.lineWidth = 1
28 | },
29 | fill(color) {
30 | this.context.fillStyle = color
31 | this.context.fill()
32 | },
33 | // 需要手动 stroke 或 fill 的
34 | drawRect(x, y, width, height, radius, style='solid', lineWidth=1) {
35 | if (style === 'dotted') {
36 | this.context.setLineDash([lineWidth, lineWidth])
37 | } else if (style === 'dashed') {
38 | this.context.setLineDash([4 * lineWidth, 4 * lineWidth])
39 | } else {
40 | this.context.lineCap = 'square'
41 | }
42 | x += lineWidth / 2
43 | y += lineWidth / 2
44 | width -= lineWidth
45 | height -= lineWidth
46 | this.context.beginPath()
47 | this.context.moveTo(x, y + radius)
48 | this.context.lineTo(x, y + height - radius)
49 | this.context.quadraticCurveTo(x, y + height, x + radius, y + height)
50 | this.context.lineTo(x + width - radius, y + height)
51 | this.context.quadraticCurveTo(x + width, y + height, x + width, y + height - radius)
52 | this.context.lineTo(x + width, y + radius)
53 | this.context.quadraticCurveTo(x + width, y, x + width - radius, y)
54 | this.context.lineTo(x + radius, y)
55 | this.context.quadraticCurveTo(x, y, x, y + radius)
56 | },
57 | drawCircle(x, y, radius) {
58 | this.context.beginPath()
59 | this.context.arc(x, y, radius, 0, 2 * Math.PI)
60 | },
61 | // 无需手动 stroke 或 fill 的
62 | drawLine(startX, startY, endX, endY, color='black') {
63 | this.context.beginPath()
64 | this.context.moveTo(startX, startY)
65 | this.context.lineTo(endX, endY)
66 | this.context.strokeStyle = color
67 | this.context.stroke()
68 | },
69 | drawText(content, x, y, fontSize, fontColor, align='left') {
70 | this.context.font = `${fontSize}px sans-serif`
71 | this.context.fillStyle = fontColor
72 | this.context.textAlign = align
73 | this.context.fillText(content, x, y)
74 | },
75 | drawImage(image, x, y, width, height, radius=0) {
76 | if (radius !== 0) {
77 | this.context.save()
78 | this.context.beginPath()
79 | this.context.moveTo(x + radius, y)
80 | this.context.arcTo(x + width, y, x + width, y + height, radius)
81 | this.context.arcTo(x + width, y + height, x, y + height, radius)
82 | this.context.arcTo(x, y + height, x, y, radius)
83 | this.context.arcTo(x, y, x + width, y, radius)
84 | this.context.closePath()
85 | this.context.clip()
86 | this.context.drawImage(image, x, y, width, height)
87 | this.context.restore()
88 | } else {
89 | this.context.drawImage(image, x, y, width, height)
90 | }
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/template/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "files.associations": {
3 | "*.ui": "html"
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/template/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "canvasui-app",
3 | "version": "1.0.0",
4 | "description": "",
5 | "scripts": {
6 | "build": "node ./script/build.js"
7 | },
8 | "author": "",
9 | "license": "Anti 996"
10 | }
11 |
--------------------------------------------------------------------------------
/template/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | CanvasUI App
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/template/script/build.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs')
2 | const path = require('path')
3 | const http = require('http')
4 | const compile = require('../compiler/compile')
5 |
6 |
7 | function load(response) {
8 | // build
9 | let componentJson = {}
10 | function build(directory) {
11 | let files = fs.readdirSync(directory)
12 | for (let file of files) {
13 | let filePath = `${directory}/${file}`
14 | if (fs.lstatSync(filePath).isDirectory()) {
15 | build(filePath)
16 | } else if (path.extname(file) === '.ui') {
17 | let sourceCode = fs.readFileSync(`${directory}/${file}`, 'utf8')
18 | componentJson[path.basename(file, '.ui')] = compile(sourceCode)
19 | }
20 | }
21 | }
22 | build(path.join(__dirname, '../src'))
23 | let main = `let componentJson = ${JSON.stringify(componentJson, (key, value) => {
24 | if (typeof value === 'function') {
25 | return value.toString()
26 | } else {
27 | return value
28 | }
29 | }, 4)}; main();`
30 | fs.writeFileSync(path.join(__dirname, '../public/main.js'), main)
31 | // hotReload
32 | let hotReload = `
33 |
41 | `
42 | let data = fs.readFileSync(path.join(__dirname, '../public/index.html'), 'utf8') + hotReload
43 | // send response
44 | response.writeHead(200, { 'Content-Type': 'text/html' })
45 | response.end(data)
46 | }
47 |
48 |
49 | function runServer() {
50 | let fileChanged = false
51 | fs.watch(path.join(__dirname, '../src'), { recursive: true }, () => {
52 | fileChanged = true
53 | })
54 |
55 | http.createServer((request, response) => {
56 | try {
57 | let url = request.url
58 | if (url === '/') {
59 | load(response)
60 | } else if (url === '/reload') {
61 | response.writeHead(200, {
62 | 'Content-Type': 'text/event-stream',
63 | 'Connection': 'keep-alive',
64 | 'Cache-Control': 'no-cache'
65 | })
66 | if (fileChanged) {
67 | fileChanged = false
68 | response.end('data: reload\n\n')
69 | } else {
70 | response.end()
71 | }
72 | } else {
73 | let type = {
74 | js: 'application/javascript',
75 | jpg: 'image/jpeg',
76 | png: 'image/png',
77 | }[path.extname(url).slice(1)]
78 | if (type !== undefined) {
79 | let data = fs.readFileSync(path.join(__dirname, `../public${url}`))
80 | response.writeHead(200, { 'Content-Type': type })
81 | response.end(data)
82 | }
83 | }
84 | } catch {
85 | response.end()
86 | }
87 | }).listen(3000)
88 | console.log('server running at http://127.0.0.1:3000/')
89 | }
90 |
91 |
92 | runServer()
93 |
--------------------------------------------------------------------------------
/template/src/main.ui:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
14 |
15 |
21 |
--------------------------------------------------------------------------------