├── .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 | 41 | 42 | 48 | ``` 49 | 50 | 51 | ## Built-in components 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 |
component namesupported propssupported CSS
<button>label, @click
<checkbox>value, label
<color>value
<div>width, height, padding, margin, border, border-radius, background
<image>pathwidth, height, border-radius
<input>value, hintwidth
<radio>value, label, option
<select>value, options
<slider>valuewidth
<switch>value
<template>width, height
<text>contentfont-size, color
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 | // ') { 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 | 14 | 15 | 21 | --------------------------------------------------------------------------------