├── test ├── _global.ts ├── helpers │ └── mock-ctx.ts ├── tsconfig.json ├── utils.ts ├── mixins │ ├── context.ts │ ├── shape.ts │ └── props-updated.ts └── renderer.ts ├── README.md ├── testem.yml ├── .gitignore ├── examples └── basic │ ├── main.js │ ├── index.html │ └── App.vue ├── src ├── global.ts ├── declarations │ ├── vue.d.ts │ ├── dev.d.ts │ └── index.d.ts ├── meta │ ├── Property.ts │ ├── Rotate.ts │ ├── Scale.ts │ ├── Translate.ts │ └── Transform.ts ├── contexts │ └── Context2d.ts ├── index.ts ├── mixins │ ├── shape.ts │ ├── props-updated.ts │ ├── property.ts │ ├── notify.ts │ └── context.ts ├── shapes │ ├── Circle.ts │ └── Rect.ts ├── renderer.ts └── utils.ts ├── .travis.yml ├── tsconfig.json ├── scripts ├── webpack.config.test.js ├── webpack.config.example.js └── rollup.config.js ├── LICENSE ├── tslint.json └── package.json /test/_global.ts: -------------------------------------------------------------------------------- 1 | import '../src/global.ts' -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vue-canvas 2 | 3 | ## License 4 | 5 | MIT 6 | -------------------------------------------------------------------------------- /testem.yml: -------------------------------------------------------------------------------- 1 | --- 2 | framework: mocha 3 | src_files: 4 | - .tmp/test.js 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | typings/ 3 | 4 | /dist/ 5 | /.tmp/ 6 | /examples/*/__build__.js 7 | -------------------------------------------------------------------------------- /examples/basic/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import App from './App' 3 | 4 | new Vue({ 5 | render: h => h(App) 6 | }).$mount('#app') 7 | -------------------------------------------------------------------------------- /test/helpers/mock-ctx.ts: -------------------------------------------------------------------------------- 1 | export class MockCtx { 2 | canvas = { width: 0, height: 0 } 3 | 4 | clearRect () {} 5 | save() {} 6 | restore() {} 7 | } -------------------------------------------------------------------------------- /src/global.ts: -------------------------------------------------------------------------------- 1 | import * as Vue from 'vue' 2 | 3 | // set merge strategy of canvas option 4 | const strategies = Vue.config.optionMergeStrategies 5 | strategies.canvas = strategies.methods -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "5" 4 | before_script: 5 | - "export DISPLAY=:99.0" 6 | - "sh -e /etc/init.d/xvfb start" 7 | - sleep 3 # give xvfb some time to start 8 | script: 9 | - echo 10 | -------------------------------------------------------------------------------- /examples/basic/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Basic Example 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "target": "es5", 5 | "lib": [ 6 | "dom", 7 | "es6" 8 | ], 9 | "sourceMap": true 10 | }, 11 | "include": [ 12 | "**/*.ts", 13 | "../src/**/*.ts", 14 | "../node_modules/@types/mocha/index.d.ts" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /src/declarations/vue.d.ts: -------------------------------------------------------------------------------- 1 | import * as Vue from 'vue' 2 | import { Dictionary, CanvasOptions } from './index' 3 | 4 | declare module 'vue/types/options' { 5 | interface ComponentOptions { 6 | propsUpdated?: (this: V, newProps: Dictionary, oldProps: Dictionary) => void; 7 | canvas?: CanvasOptions 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/meta/Property.ts: -------------------------------------------------------------------------------- 1 | import * as Vue from 'vue' 2 | import { ComponentOptions } from 'vue' 3 | 4 | import propertyMixin from '../mixins/property' 5 | import { noop } from '../utils' 6 | 7 | export default { 8 | mixins: [propertyMixin], 9 | 10 | render (h) { 11 | return h('span', this.$slots['default']) 12 | } 13 | } as ComponentOptions -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "target": "es5", 5 | "lib": [ 6 | "dom", 7 | "es6" 8 | ], 9 | "noImplicitAny": true, 10 | "noImplicitThis": true, 11 | "strictNullChecks": true, 12 | "sourceMap": true 13 | }, 14 | "include": [ 15 | "src/**/*.ts" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /src/declarations/dev.d.ts: -------------------------------------------------------------------------------- 1 | // this file should not expose the user land 2 | // only use for development 3 | 4 | import 'vue' 5 | 6 | declare global { 7 | const process: { 8 | env: { 9 | NODE_ENV?: string 10 | } 11 | } 12 | } 13 | 14 | declare module 'vue/types/vue' { 15 | interface Vue { 16 | readonly _componentTag: string 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/declarations/index.d.ts: -------------------------------------------------------------------------------- 1 | import * as Vue from 'vue' 2 | import './vue' 3 | 4 | export type Dictionary = { [key: string]: T } 5 | 6 | export interface CanvasOptions { 7 | render? (this: V, ctx: CanvasRenderingContext2D): void 8 | getContext? (this: V): CanvasRenderingContext2D 9 | applyDrawingState? (this: V, ctx: CanvasRenderingContext2D): void 10 | } 11 | -------------------------------------------------------------------------------- /src/contexts/Context2d.ts: -------------------------------------------------------------------------------- 1 | import * as Vue from 'vue' 2 | import { ComponentOptions } from 'vue' 3 | import contextMixin from '../mixins/context' 4 | 5 | export default { 6 | mixins: [contextMixin], 7 | 8 | canvas: { 9 | getContext () { 10 | const canvas: HTMLCanvasElement = this.$el as any 11 | return canvas.getContext('2d')! 12 | } 13 | } 14 | } as ComponentOptions 15 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import './global' 2 | export { default as Context2d } from './contexts/Context2d' 3 | export { default as Property } from './meta/Property' 4 | export { default as Transform } from './meta/Transform' 5 | export { default as Translate } from './meta/Translate' 6 | export { default as Rotate } from './meta/Rotate' 7 | export { default as Scale } from './meta/Scale' 8 | export { default as Rect } from './shapes/Rect' 9 | export { default as Circle } from './shapes/Circle' 10 | -------------------------------------------------------------------------------- /src/mixins/shape.ts: -------------------------------------------------------------------------------- 1 | import * as Vue from 'vue' 2 | import { ComponentOptions } from 'vue' 3 | import { Dictionary } from '../declarations' 4 | 5 | import propertyMixin from './property' 6 | import { assert } from '../utils' 7 | 8 | export default { 9 | mixins: [propertyMixin], 10 | 11 | canvas: { 12 | render () { 13 | assert(false, 'must be implemented canvas render function') 14 | } 15 | }, 16 | 17 | render (h) { 18 | // always return empty node 19 | return h() 20 | } 21 | } as ComponentOptions 22 | -------------------------------------------------------------------------------- /src/meta/Rotate.ts: -------------------------------------------------------------------------------- 1 | import * as Vue from 'vue' 2 | import { ComponentOptions } from 'vue' 3 | 4 | import notifyMixin from '../mixins/notify' 5 | 6 | interface RotateMixin extends Vue { 7 | angle: number 8 | } 9 | 10 | export default { 11 | mixins: [notifyMixin], 12 | 13 | props: { 14 | angle: { 15 | type: Number, 16 | required: true 17 | } 18 | }, 19 | 20 | canvas: { 21 | applyDrawingState (ctx) { 22 | ctx.rotate(this.angle) 23 | } 24 | }, 25 | 26 | render (h) { 27 | return h('span', this.$slots['default']) 28 | } 29 | } as ComponentOptions -------------------------------------------------------------------------------- /scripts/webpack.config.test.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const glob = require('glob') 3 | 4 | const testFiles = glob.sync(resolve('../test/**/*.ts')) 5 | 6 | module.exports = { 7 | entry: testFiles, 8 | output: { 9 | path: resolve('../.tmp'), 10 | filename: 'test.js' 11 | }, 12 | resolve: { 13 | extensions: ['.js', '.json', '.ts'] 14 | }, 15 | module: { 16 | rules: [ 17 | { test: /\.ts$/, loader: 'webpack-espower!ts' }, 18 | { test: /\.json$/, loader: 'json-loader' } 19 | ] 20 | }, 21 | devtool: 'source-map' 22 | } 23 | 24 | function resolve (p) { 25 | return path.resolve(__dirname, p) 26 | } -------------------------------------------------------------------------------- /src/meta/Scale.ts: -------------------------------------------------------------------------------- 1 | import * as Vue from 'vue' 2 | import { ComponentOptions } from 'vue' 3 | 4 | import notifyMixin from '../mixins/notify' 5 | 6 | interface ScaleMixin extends Vue { 7 | x: number 8 | y: number 9 | } 10 | 11 | export default { 12 | mixins: [notifyMixin], 13 | 14 | props: { 15 | x: { 16 | type: Number, 17 | default: 1 18 | }, 19 | y: { 20 | type: Number, 21 | default: 1 22 | } 23 | }, 24 | 25 | canvas: { 26 | applyDrawingState (ctx) { 27 | ctx.scale(this.x, this.y) 28 | } 29 | }, 30 | 31 | render (h) { 32 | return h('span', this.$slots['default']) 33 | } 34 | } as ComponentOptions -------------------------------------------------------------------------------- /src/meta/Translate.ts: -------------------------------------------------------------------------------- 1 | import * as Vue from 'vue' 2 | import { ComponentOptions } from 'vue' 3 | 4 | import notifyMixin from '../mixins/notify' 5 | 6 | interface TranslateMixin extends Vue { 7 | x: number 8 | y: number 9 | } 10 | 11 | export default { 12 | mixins: [notifyMixin], 13 | 14 | props: { 15 | x: { 16 | type: Number, 17 | default: 0 18 | }, 19 | y: { 20 | type: Number, 21 | default: 0 22 | } 23 | }, 24 | 25 | canvas: { 26 | applyDrawingState (ctx) { 27 | ctx.translate(this.x, this.y) 28 | } 29 | }, 30 | 31 | render (h) { 32 | return h('span', this.$slots['default']) 33 | } 34 | } as ComponentOptions -------------------------------------------------------------------------------- /scripts/webpack.config.example.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | module.exports = { 4 | context: path.resolve(__dirname, '../examples'), 5 | entry: makeEntries([ 6 | 'basic' 7 | ]), 8 | output: { 9 | path: path.resolve(__dirname, '../examples'), 10 | filename: '[name]/__build__.js' 11 | }, 12 | resolve: { 13 | extensions: ['.js', '.json', '.ts', '.vue'] 14 | }, 15 | module: { 16 | rules: [ 17 | { test: /\.vue$/, loader: 'vue' }, 18 | { test: /\.ts$/, loader: 'ts' }, 19 | { test: /\.js$/, loader: 'buble', exclude: /node_modules/ } 20 | ] 21 | }, 22 | devtool: 'inline-source-map' 23 | } 24 | 25 | function makeEntries (names) { 26 | const res = {} 27 | names.forEach(name => { 28 | res[name] = `./${name}/main.js` 29 | }) 30 | return res 31 | } 32 | -------------------------------------------------------------------------------- /src/meta/Transform.ts: -------------------------------------------------------------------------------- 1 | import * as Vue from 'vue' 2 | import { ComponentOptions } from 'vue' 3 | 4 | import notifyMixin from '../mixins/notify' 5 | 6 | interface TransformMixin extends Vue { 7 | matrix: number[] 8 | } 9 | 10 | export default { 11 | mixins: [notifyMixin], 12 | 13 | props: { 14 | matrix: { 15 | type: Array, 16 | required: true, 17 | validator (value) { 18 | return value.length === 6 19 | } 20 | } 21 | }, 22 | 23 | canvas: { 24 | applyDrawingState (ctx) { 25 | ctx.transform( 26 | this.matrix[0], 27 | this.matrix[1], 28 | this.matrix[2], 29 | this.matrix[3], 30 | this.matrix[4], 31 | this.matrix[5] 32 | ) 33 | } 34 | }, 35 | 36 | render (h) { 37 | return h('span', this.$slots['default']) 38 | } 39 | } as ComponentOptions -------------------------------------------------------------------------------- /src/mixins/props-updated.ts: -------------------------------------------------------------------------------- 1 | import * as Vue from 'vue' 2 | import { ComponentOptions } from 'vue' 3 | import { pick } from '../utils' 4 | 5 | export default { 6 | created () { 7 | const hook = this.$options.propsUpdated 8 | if (typeof hook !== 'function') return 9 | 10 | const propKeys = Object.keys(this.$options.props) 11 | let prevProps = pick(this, propKeys) 12 | 13 | this.$watch((vm: Vue) => { 14 | // check the update of any props 15 | const isUpdated = propKeys.reduce((acc, key) => { 16 | return acc || prevProps[key] !== (vm as any)[key] 17 | }, false) 18 | 19 | // return previous props to prevent calling propsUpdated hook 20 | if (!isUpdated) return prevProps 21 | 22 | // create new object for updated props 23 | // and trigger propsUpdated hook 24 | prevProps = pick(this, propKeys) 25 | return prevProps 26 | }, hook) 27 | } 28 | } as ComponentOptions 29 | 30 | -------------------------------------------------------------------------------- /src/shapes/Circle.ts: -------------------------------------------------------------------------------- 1 | import * as Vue from 'vue' 2 | import { ComponentOptions } from 'vue' 3 | 4 | import shapeMixin from '../mixins/shape' 5 | 6 | interface Circle extends Vue { 7 | fill: boolean 8 | stroke: boolean 9 | x: number 10 | y: number 11 | radius: number 12 | } 13 | 14 | export default { 15 | mixins: [shapeMixin], 16 | 17 | props: { 18 | fill: Boolean, 19 | stroke: Boolean, 20 | x: { 21 | type: Number, 22 | required: true 23 | }, 24 | y: { 25 | type: Number, 26 | required: true 27 | }, 28 | radius: { 29 | type: Number, 30 | required: true 31 | } 32 | }, 33 | 34 | canvas: { 35 | render (ctx) { 36 | ctx.beginPath() 37 | ctx.arc(this.x, this.y, this.radius, 0, 2 * Math.PI) 38 | 39 | if (this.fill) { 40 | ctx.fill() 41 | } 42 | 43 | if (this.stroke) { 44 | ctx.stroke() 45 | } 46 | } 47 | } 48 | } as ComponentOptions -------------------------------------------------------------------------------- /src/mixins/property.ts: -------------------------------------------------------------------------------- 1 | import * as Vue from 'vue' 2 | import { ComponentOptions } from 'vue' 3 | 4 | import notifyMixin from './notify' 5 | 6 | const props = { 7 | strokeStyle: String, 8 | fillStyle: String, 9 | globalAlpha: Number, 10 | lineWidth: Number, 11 | lineCap: String, 12 | lineJoin: String, 13 | miterLimit: Number, 14 | shadowOffsetX: Number, 15 | shadowOffsetY: Number, 16 | shadowBlur: Number, 17 | shadowColor: String, 18 | globalCompositeOperations: String, 19 | font: String, 20 | textAlign: String, 21 | textBaseLine: String 22 | } 23 | 24 | const propKeys = Object.keys(props) 25 | 26 | export default { 27 | mixins: [notifyMixin], 28 | 29 | props, 30 | 31 | canvas: { 32 | applyDrawingState (ctx) { 33 | propKeys 34 | .map(key => ({ 35 | key, 36 | value: (this as any)[key] 37 | })) 38 | .filter(p => p.value != null) 39 | .forEach(p => { 40 | (ctx as any)[p.key] = p.value 41 | }) 42 | } 43 | } 44 | } as ComponentOptions -------------------------------------------------------------------------------- /src/shapes/Rect.ts: -------------------------------------------------------------------------------- 1 | import * as Vue from 'vue' 2 | import { ComponentOptions } from 'vue' 3 | import shapeMixin from '../mixins/shape' 4 | 5 | interface Rectangle extends Vue { 6 | fill?: boolean 7 | stroke?: boolean 8 | x: number 9 | y: number 10 | width: number 11 | height: number 12 | } 13 | 14 | export default { 15 | mixins: [shapeMixin], 16 | 17 | props: { 18 | fill: Boolean, 19 | stroke: Boolean, 20 | x: { 21 | type: Number, 22 | required: true 23 | }, 24 | y: { 25 | type: Number, 26 | required: true 27 | }, 28 | width: { 29 | type: Number, 30 | required: true 31 | }, 32 | height: { 33 | type: Number, 34 | required: true 35 | } 36 | }, 37 | 38 | canvas: { 39 | render (ctx) { 40 | ctx.beginPath() 41 | ctx.rect(this.x, this.y, this.width, this.height) 42 | if (this.fill) { 43 | ctx.fill() 44 | } 45 | if (this.stroke) { 46 | ctx.stroke() 47 | } 48 | } 49 | } 50 | } as ComponentOptions 51 | -------------------------------------------------------------------------------- /src/mixins/notify.ts: -------------------------------------------------------------------------------- 1 | import * as Vue from 'vue' 2 | import { ComponentOptions } from 'vue' 3 | 4 | import propsUpdatedMixin from './props-updated' 5 | import { assert } from '../utils' 6 | 7 | interface NotifyMixin extends Vue { 8 | eventBus: Vue 9 | findEventBus (target: Vue & { _eventBus?: Vue }): Vue 10 | } 11 | 12 | export default { 13 | mixins: [propsUpdatedMixin], 14 | 15 | propsUpdated (newProps, oldProps) { 16 | this.eventBus.$emit('update') 17 | }, 18 | 19 | computed: { 20 | eventBus (): Vue { 21 | return this.findEventBus(this) 22 | } 23 | }, 24 | 25 | methods: { 26 | findEventBus (this: NotifyMixin, target: Vue & { _eventBus?: Vue }): Vue { 27 | if (target._eventBus) { 28 | return target._eventBus 29 | } 30 | 31 | const parent = target.$parent 32 | assert( 33 | parent !== undefined, 34 | `<${this._componentTag}> must be descendant of a context component` 35 | ) 36 | 37 | return this.findEventBus(parent) 38 | } 39 | } 40 | } as ComponentOptions -------------------------------------------------------------------------------- /test/utils.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'power-assert' 2 | import * as Vue from 'vue' 3 | import { throttledTick, merge, pick } from '../src/utils' 4 | 5 | describe('utils', () => { 6 | it('merge', () => { 7 | const actual = merge({ a: 1, b: 2 }, { b: 3, c: 4 }) 8 | assert.deepStrictEqual(actual, { 9 | a: 1, 10 | b: 3, 11 | c: 4 12 | }) 13 | }) 14 | 15 | it('pick', () => { 16 | const actual = pick({ 17 | a: 1, 18 | b: 2, 19 | c: 3 20 | }, ['a', 'c']) 21 | 22 | assert.deepStrictEqual(actual, { 23 | a: 1, 24 | c: 3 25 | }) 26 | }) 27 | 28 | it('throttledTick', done => { 29 | let count = 0 30 | function fn () { 31 | count++ 32 | } 33 | throttledTick(fn) 34 | throttledTick(fn) 35 | throttledTick(fn) 36 | 37 | Vue.nextTick(() => { 38 | assert(count === 1) 39 | 40 | throttledTick(fn) 41 | throttledTick(fn) 42 | 43 | Vue.nextTick(() => { 44 | assert(count === 2) 45 | done() 46 | }) 47 | }) 48 | }) 49 | }) 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 katashin 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /src/renderer.ts: -------------------------------------------------------------------------------- 1 | import * as Vue from 'vue' 2 | import { warn } from './utils' 3 | 4 | export class Renderer { 5 | constructor (private ctx: CanvasRenderingContext2D) {} 6 | 7 | clear (): void { 8 | const { width, height } = this.ctx.canvas 9 | this.ctx.clearRect(0, 0, width, height) 10 | } 11 | 12 | render (vm: Vue): void { 13 | const options = vm.$options.canvas! 14 | if (process.env.NODE_ENV !== 'production') { 15 | if (!options) { 16 | warn( 17 | 'canvas options must be defined for descendants of context ' + 18 | `<${vm._componentTag}>` 19 | ) 20 | return 21 | } 22 | } 23 | 24 | // set drawing state 25 | this.ctx.save() 26 | if (typeof options.applyDrawingState === 'function') { 27 | options.applyDrawingState.call(vm, this.ctx) 28 | } 29 | 30 | // render target shape 31 | if (typeof options.render === 'function') { 32 | options.render.call(vm, this.ctx) 33 | } 34 | 35 | // render child shapes 36 | vm.$children.forEach(child => this.render(child)) 37 | 38 | // restore drawing state 39 | this.ctx.restore() 40 | } 41 | } -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "class-name": true, 4 | "comment-format": [ 5 | true, 6 | "check-space" 7 | ], 8 | "forin": true, 9 | "indent": [ 10 | true, 11 | "spaces" 12 | ], 13 | "jsdoc-format": true, 14 | "label-position": true, 15 | "label-undefined": true, 16 | "member-ordering": true, 17 | "no-arg": true, 18 | "no-console": true, 19 | "no-construct": true, 20 | "no-duplicate-key": true, 21 | "no-duplicate-variable": true, 22 | "no-eval": true, 23 | "no-trailing-whitespace": true, 24 | "no-unreachable": true, 25 | "no-unused-expression": true, 26 | "no-unused-variable": true, 27 | "no-var-keyword": true, 28 | "quotemark": [ 29 | true, 30 | "single", 31 | "jsx-double" 32 | ], 33 | "semicolon": [ 34 | true, 35 | "never" 36 | ], 37 | "trailing-comma": [ 38 | true, 39 | { 40 | "singleline": "never", 41 | "multiline": "never" 42 | } 43 | ], 44 | "typedef-whitespace": [ 45 | true, 46 | "onespace" 47 | ], 48 | "whitespace": [ 49 | true, 50 | "check-branch", 51 | "check-decl", 52 | "check-operator", 53 | "check-module", 54 | "check-separator", 55 | "check-type" 56 | ] 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/mixins/context.ts: -------------------------------------------------------------------------------- 1 | import * as Vue from 'vue' 2 | import { ComponentOptions } from 'vue' 3 | 4 | import propertyMixin from './property' 5 | import { Renderer } from '../renderer' 6 | import { noop, throttledTick } from '../utils' 7 | 8 | interface ContextMixin extends Vue { 9 | height: number 10 | width: number 11 | _eventBus: Vue 12 | _renderer: Renderer 13 | render (): void 14 | } 15 | 16 | export default { 17 | mixins: [propertyMixin], 18 | 19 | props: { 20 | height: { 21 | type: Number, 22 | default: 150 23 | }, 24 | width: { 25 | type: Number, 26 | default: 300 27 | } 28 | }, 29 | 30 | beforeCreate () { 31 | this._eventBus = new Vue() 32 | this._eventBus.$on('update', () => { 33 | throttledTick(this.render) 34 | }) 35 | }, 36 | 37 | mounted () { 38 | const ctx = this.$options.canvas!.getContext!.call(this) 39 | this._renderer = new Renderer(ctx) 40 | this.render() 41 | }, 42 | 43 | methods: { 44 | render (this: ContextMixin) { 45 | this._renderer.clear() 46 | this._renderer.render(this) 47 | } 48 | }, 49 | 50 | render (h) { 51 | const { height, width } = this 52 | 53 | return h('canvas', { 54 | attrs: { height, width } 55 | }, this.$slots['default']) 56 | } 57 | } as ComponentOptions 58 | -------------------------------------------------------------------------------- /scripts/rollup.config.js: -------------------------------------------------------------------------------- 1 | const typescript = require('rollup-plugin-typescript') 2 | const replace = require('rollup-plugin-replace') 3 | const meta = require('../package.json') 4 | 5 | const config = { 6 | entry: 'src/index.ts', 7 | moduleName: 'VueCanvas', 8 | plugins: [ 9 | typescript({ 10 | typescript: require('typescript') 11 | }) 12 | ], 13 | external: ['vue'], 14 | globals: { 15 | vue: 'Vue' 16 | }, 17 | banner: `/*! 18 | * ${meta.name} v${meta.version} 19 | * ${meta.homepage} 20 | * 21 | * @license 22 | * Copyright (c) 2016 ${meta.author} 23 | * Released under the MIT license 24 | * ${meta.homepage}/blob/master/LICENSE 25 | */` 26 | } 27 | 28 | switch (process.env.BUILD) { 29 | case 'commonjs': 30 | config.dest = `dist/${meta.name}.cjs.js` 31 | config.format = 'cjs' 32 | break 33 | case 'development': 34 | config.dest = `dist/${meta.name}.js` 35 | config.format = 'umd' 36 | config.plugins.push( 37 | replace({ 38 | 'process.env.NODE_ENV': JSON.stringify('development') 39 | }) 40 | ) 41 | break 42 | case 'production': 43 | config.format = 'umd' 44 | config.plugins.push( 45 | replace({ 46 | 'process.env.NODE_ENV': JSON.stringify('production') 47 | }) 48 | ) 49 | break 50 | default: 51 | throw new Error('Unknown build environment') 52 | } 53 | 54 | module.exports = config 55 | -------------------------------------------------------------------------------- /test/mixins/context.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'power-assert' 2 | import * as sinon from 'sinon' 3 | import * as Vue from 'vue' 4 | import contextMixin from '../../src/mixins/context' 5 | import { MockCtx } from '../helpers/mock-ctx' 6 | 7 | describe('Context Mixin', () => { 8 | const render = (h: any) => h() 9 | const mockCtx: any = new MockCtx() 10 | 11 | it('mount canvas element with specifying width and height', () => { 12 | const vm = new Vue({ 13 | mixins: [contextMixin], 14 | propsData: { 15 | width: 500, 16 | height: 400 17 | }, 18 | canvas: { 19 | getContext: () => mockCtx 20 | } 21 | }).$mount() 22 | 23 | const el: any = vm.$el 24 | assert(el instanceof HTMLCanvasElement) 25 | assert(el.width === 500) 26 | assert(el.height === 400) 27 | }) 28 | 29 | it('observes all updates and renders only once', done => { 30 | const vm: any = new Vue({ 31 | mixins: [contextMixin], 32 | canvas: { 33 | getContext: () => mockCtx 34 | } 35 | }) 36 | 37 | const spy = sinon.spy(vm, 'render') 38 | 39 | vm.$mount() 40 | 41 | Vue.nextTick(() => { 42 | assert(spy.callCount === 1) 43 | 44 | vm.eventBus.$emit('update') 45 | vm.eventBus.$emit('update') 46 | 47 | Vue.nextTick(() => { 48 | assert(spy.callCount === 2) 49 | done() 50 | }) 51 | }) 52 | }) 53 | }) 54 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import * as Vue from 'vue' 2 | import { Dictionary } from './declarations' 3 | 4 | export const noop = () => {} 5 | 6 | export function warn (message: string): void { 7 | console.error('[vue-canvas] ' + message) 8 | } 9 | 10 | export function assert (condition: boolean, message: string): void { 11 | if (!condition) { 12 | throw new Error('[vue-canvas] ' + message) 13 | } 14 | } 15 | 16 | export function contains (list: T[], value: T): boolean { 17 | return list.indexOf(value) > -1 18 | } 19 | 20 | export function merge (t: T, u: U): T & U { 21 | const res: any = {} 22 | const list: any[] = [t, u] 23 | list.forEach(obj => { 24 | Object.keys(obj).forEach(key => { 25 | res[key] = obj[key] 26 | }) 27 | }) 28 | return res 29 | } 30 | 31 | export function pick (obj: Dictionary, keys: string[]): Dictionary { 32 | const res: any = {} 33 | keys.forEach(key => { 34 | res[key] = obj[key] 35 | }) 36 | return res 37 | } 38 | 39 | /** 40 | * Similar to Vue.nextTick but reduce duplicated functions 41 | */ 42 | export const throttledTick = (function () { 43 | // store revered tick function to reduce duplication 44 | const ticked: (() => void)[] = [] 45 | 46 | function resetTicked (): void { 47 | ticked.length = 0 48 | } 49 | 50 | return function (fn: () => void): void { 51 | if (contains(ticked, fn)) return 52 | 53 | if (ticked.length === 0) { 54 | Vue.nextTick(resetTicked) 55 | } 56 | 57 | ticked.push(fn) 58 | Vue.nextTick(fn) 59 | } 60 | }()) 61 | -------------------------------------------------------------------------------- /test/mixins/shape.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'power-assert' 2 | import * as sinon from 'sinon' 3 | import * as Vue from 'vue' 4 | import shapeMixin from '../../src/mixins/shape' 5 | 6 | describe('Shape Mixin', () => { 7 | it('must dispatch update event to context', done => { 8 | const Target = { 9 | mixins: [shapeMixin], 10 | props: { 11 | test: Number 12 | } 13 | } 14 | 15 | const vm: any = new Vue({ 16 | data: { 17 | test: 1 18 | }, 19 | render (this: any, h) { 20 | return h(Target, { props: { test: this.test }}) 21 | } 22 | }).$mount() 23 | 24 | // dependent event bus 25 | vm._eventBus = new Vue() 26 | const spy = sinon.spy() 27 | vm._eventBus.$on('update', spy) 28 | 29 | vm.test = 2 30 | Vue.nextTick(() => { 31 | assert(spy.called) 32 | done() 33 | }) 34 | }) 35 | 36 | it('does not notify update if there are no props update', done => { 37 | const Target = { 38 | mixins: [shapeMixin], 39 | props: { 40 | test: Number 41 | } 42 | } 43 | 44 | const vm: any = new Vue({ 45 | data: { 46 | test: 1 47 | }, 48 | render (this: any, h) { 49 | return h(Target, { props: { test: this.test }}) 50 | } 51 | }).$mount() 52 | 53 | // dependent event bus 54 | vm.eventBus = new Vue() 55 | const spy = sinon.spy() 56 | vm.eventBus.$on('update', spy) 57 | 58 | vm.test = 1 59 | Vue.nextTick(() => { 60 | assert(!spy.called) 61 | done() 62 | }) 63 | }) 64 | }) -------------------------------------------------------------------------------- /examples/basic/App.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 58 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-canvas", 3 | "version": "1.0.0", 4 | "author": "katashin", 5 | "description": "", 6 | "keywords": [], 7 | "license": "MIT", 8 | "main": "dist/vue-canvas.cjs.js", 9 | "files": [ 10 | "dist" 11 | ], 12 | "homepage": "https://github.com/ktsn/vue-canvas", 13 | "bugs": "https://github.com/ktsn/vue-canvas/issues", 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/ktsn/vue-canvas.git" 17 | }, 18 | "scripts": { 19 | "prepublish": "npm run build", 20 | "clean": "rm -rf dist .tmp", 21 | "build": "run-p build:cjs build:dev build:prod", 22 | "build:cjs": "rollup -c scripts/rollup.config.js --environment BUILD:commonjs", 23 | "build:dev": "rollup -c scripts/rollup.config.js --environment BUILD:development", 24 | "build:prod": "rollup -c scripts/rollup.config.js --environment BUILD:production | uglifyjs -mc warnings=false --comments -o dist/vue-canvas.min.js", 25 | "build:example": "webpack --config scripts/webpack.config.example.js", 26 | "watch:test": "webpack --watch --config scripts/webpack.config.test.js", 27 | "testem": "testem", 28 | "lint": "tslint \"src/**/*.ts\" && tslint \"test/**/*.ts\"", 29 | "test": "run-p watch:test testem", 30 | "test:ci": "webpack --config scripts/webpack.config.test.js && testem ci --launch PhantomJS" 31 | }, 32 | "devDependencies": { 33 | "@types/mocha": "^2.2.32", 34 | "@types/power-assert": "0.0.28", 35 | "@types/sinon": "^1.16.31", 36 | "buble": "^0.13.2", 37 | "buble-loader": "^0.3.0", 38 | "css-loader": "^0.25.0", 39 | "glob": "^7.1.0", 40 | "json-loader": "^0.5.4", 41 | "npm-run-all": "^3.1.0", 42 | "power-assert": "^1.4.1", 43 | "rollup": "^0.36.0", 44 | "rollup-plugin-replace": "^1.1.1", 45 | "rollup-plugin-typescript": "^0.8.1", 46 | "sinon": "^2.0.0-pre.3", 47 | "testem": "^1.12.0", 48 | "ts-loader": "^0.8.2", 49 | "tslint": "^3.15.1", 50 | "typescript": "^2.0.3", 51 | "vue": "^2.0.1", 52 | "vue-loader": "^9.5.1", 53 | "webpack": "^2.1.0-beta.25", 54 | "webpack-espower-loader": "^1.0.1" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /test/renderer.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'power-assert' 2 | import * as sinon from 'sinon' 3 | import * as Vue from 'vue' 4 | import { Renderer } from '../src/renderer' 5 | import { MockCtx } from './helpers/mock-ctx' 6 | 7 | describe('Renderer', () => { 8 | const render = h => h() 9 | 10 | it('renders all children', done => { 11 | const mockCtx: any = new MockCtx() 12 | const renderer = new Renderer(mockCtx) 13 | 14 | const spy1 = sinon.spy() 15 | const spy2 = sinon.spy() 16 | const spy3 = sinon.spy() 17 | 18 | const Child1 = { 19 | canvas: { 20 | render: spy1 21 | }, 22 | render 23 | } 24 | 25 | const Child2 = { 26 | canvas: { 27 | render: spy2 28 | }, 29 | render 30 | } 31 | 32 | const vm = new Vue({ 33 | canvas: { 34 | render: spy3 35 | }, 36 | render: h => h('canvas', [ 37 | h(Child1), 38 | h(Child2) 39 | ]) 40 | }).$mount() 41 | 42 | Vue.nextTick(() => { 43 | renderer.render(vm) 44 | 45 | assert(spy1.called) 46 | assert(spy2.called) 47 | assert(spy3.called) 48 | 49 | done() 50 | }) 51 | }) 52 | 53 | it('applies drawing state for each element', () => { 54 | const mockCtx: any = new MockCtx() 55 | const renderer = new Renderer(mockCtx) 56 | 57 | const Child1 = { 58 | canvas: { 59 | render (ctx) { 60 | assert(ctx.fillStyle === '#ff0000') 61 | }, 62 | applyDrawingState (ctx) { 63 | ctx.fillStyle = '#ff0000' 64 | } 65 | }, 66 | render 67 | } 68 | 69 | const Child2 = { 70 | canvas: { 71 | render (ctx) { 72 | assert(ctx.strokeStyle === '#00ff00') 73 | }, 74 | applyDrawingState (ctx) { 75 | ctx.strokeStyle = '#00ff00' 76 | } 77 | }, 78 | render 79 | } 80 | 81 | const vm = new Vue({ 82 | canvas: { 83 | render: () => {} 84 | }, 85 | render: h => h('canvas', [ 86 | h(Child1), 87 | h(Child2) 88 | ]) 89 | }).$mount() 90 | 91 | renderer.render(vm) 92 | }) 93 | }) -------------------------------------------------------------------------------- /test/mixins/props-updated.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'power-assert' 2 | import * as sinon from 'sinon' 3 | import * as Vue from 'vue' 4 | import propsUpdatedMixin from '../../src/mixins/props-updated' 5 | 6 | describe('Props Updated Mixin', () => { 7 | const render = h => h() 8 | 9 | it('call propsUpdated hook when props are updated', done => { 10 | const Component = { 11 | mixins: [propsUpdatedMixin], 12 | props: { 13 | test: Number 14 | }, 15 | propsUpdated () { 16 | done() 17 | }, 18 | render 19 | } 20 | 21 | const vm: any = new Vue({ 22 | data: { 23 | test: 1 24 | }, 25 | render (this: any, h) { 26 | return h(Component, { 27 | props: { 28 | test: this.test 29 | } 30 | }) 31 | } 32 | }).$mount() 33 | 34 | vm.test = 2 35 | }) 36 | 37 | it('call the hook even if a prop is undefined initially', done => { 38 | const Component = { 39 | mixins: [propsUpdatedMixin], 40 | props: { 41 | test: Number 42 | }, 43 | propsUpdated () { 44 | done() 45 | }, 46 | render 47 | } 48 | 49 | const vm: any = new Vue({ 50 | data: { 51 | test: null 52 | }, 53 | render (this: any, h) { 54 | let data = {} 55 | if (this.test !== null) { 56 | data = { 57 | props: { 58 | test: this.test 59 | } 60 | } 61 | } 62 | return h(Component, data) 63 | } 64 | }).$mount() 65 | 66 | vm.test = 1 67 | }) 68 | 69 | it('does not call if props are not updated', done => { 70 | const Component = { 71 | mixins: [propsUpdatedMixin], 72 | props: { 73 | test: Number 74 | }, 75 | propsUpdated () { 76 | assert.fail() 77 | }, 78 | render 79 | } 80 | 81 | const vm: any = new Vue({ 82 | data: { 83 | test: 1 84 | }, 85 | render (this: any, h) { 86 | return h(Component, { 87 | props: { 88 | test: this.test 89 | } 90 | }) 91 | } 92 | }).$mount() 93 | 94 | vm.test = 1 95 | Vue.nextTick(() => { 96 | done() 97 | }) 98 | }) 99 | 100 | it('passes prev and next props', done => { 101 | const Component = { 102 | mixins: [propsUpdatedMixin], 103 | props: { 104 | test: Number 105 | }, 106 | propsUpdated (newProps, oldProps) { 107 | assert(oldProps.test === 1) 108 | assert(newProps.test === 2) 109 | done() 110 | }, 111 | render 112 | } 113 | 114 | const vm: any = new Vue({ 115 | data: { 116 | test: 1 117 | }, 118 | render (this: any, h) { 119 | return h(Component, { 120 | props: { 121 | test: this.test 122 | } 123 | }) 124 | } 125 | }).$mount() 126 | 127 | vm.test = 2 128 | }) 129 | 130 | it('calls up to once in same tick', done => { 131 | const spy = sinon.spy() 132 | const Component = { 133 | mixins: [propsUpdatedMixin], 134 | props: { 135 | foo: String, 136 | bar: String 137 | }, 138 | propsUpdated: spy, 139 | render 140 | } 141 | 142 | const vm: any = new Vue({ 143 | data: { 144 | foo: 'foo', 145 | bar: 'bar' 146 | }, 147 | render (this: any, h) { 148 | return h(Component, { 149 | props: { 150 | foo: this.foo, 151 | bar: this.bar 152 | } 153 | }) 154 | } 155 | }).$mount() 156 | 157 | vm.foo = 'foofoo' 158 | vm.bar = 'barbar' 159 | 160 | Vue.nextTick(() => { 161 | assert(spy.callCount === 1) 162 | assert.deepStrictEqual(spy.lastCall.args, [ 163 | { foo: 'foofoo', bar: 'barbar' }, 164 | { foo: 'foo', bar: 'bar' } 165 | ]) 166 | done() 167 | }) 168 | }) 169 | }) --------------------------------------------------------------------------------