├── 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 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
25 |
26 |
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 | })
--------------------------------------------------------------------------------