利用Canvas實(shí)現(xiàn)手寫板插件

此插件基于Vue撞芍。每一大章節(jié)都有對(duì)應(yīng)的源碼和DEMO夜牡。

前兩天在公司項(xiàng)目上遇到了一個(gè)簽署合同的需求芳誓,詳細(xì)是在PAD端實(shí)現(xiàn)手寫板完成名字簽署,不需要考慮筆畫粗細(xì)、顏色等兔簇。效果如下圖所示。

效果圖

實(shí)現(xiàn)起來很簡(jiǎn)單硬耍,于是就自己寫了一個(gè)小插件垄琐。此篇文章就在此基礎(chǔ)上對(duì)筆畫粗細(xì)、顏色等加以擴(kuò)展经柴,寫一個(gè)移動(dòng)端的手寫板狸窘。

實(shí)現(xiàn)原理

對(duì)touch和canvas有所了解的可以略過本節(jié)。

在畫板上寫字坯认,整個(gè)寫字的過程大概分為三部分翻擒,分別為落筆、運(yùn)筆牛哺、提筆(自己瞎概括的)陋气。這三個(gè)步驟分別對(duì)應(yīng)了三個(gè)移動(dòng)端的監(jiān)聽方法。其中

  • 落筆對(duì)應(yīng)的為:touchstart引润,此方法當(dāng)手指觸摸到屏幕時(shí)觸發(fā)巩趁。
  • 運(yùn)筆對(duì)應(yīng)的為:touchmove,此方法當(dāng)手指在屏幕上滑動(dòng)時(shí)觸發(fā)淳附。
  • 提筆對(duì)應(yīng)的為:touchend议慰,此方法當(dāng)手指離開屏幕時(shí)觸發(fā)。

運(yùn)行這三個(gè)方法就可以模擬寫字的整個(gè)過程奴曙。
我想應(yīng)該學(xué)會(huì)了如何在移動(dòng)端控制自己手指觸控屏幕的過程别凹,但是現(xiàn)在還沒有筆跡,沒有畫板洽糟,所以我們需要先通過canvas構(gòu)建一個(gè)畫板炉菲,然后生成筆跡。下面介紹一下這個(gè)插件所需要用到的canvas相關(guān)方法脊框。

Canvas

  • 方法
  1. getContext('2d') 獲取一個(gè)context 2d對(duì)象颁督,即渲染上下文,其中有很多畫圖相關(guān)的方法浇雹,對(duì)應(yīng)的還有3d對(duì)象沉御。
  2. beginPath() 開始繪制路徑
  3. lineTo() 線段的終點(diǎn)
  4. moveTo() 線段的地點(diǎn)
  5. stroke() 給線段上色
  6. closePath() 結(jié)束路徑繪制
  7. clearRect(x1, y1, x2, y2) 清空一定范圍內(nèi)的內(nèi)容
  8. toDataURL() 將Canvas數(shù)據(jù)重新轉(zhuǎn)化成圖片文件
  • 線條相關(guān)屬性
  1. strokeStyle 線條顏色
  2. lineWidth 線條寬度
  3. lineCap 線條結(jié)束線帽
    • butt:默認(rèn)值。平直邊緣
    • round:圓形線帽
    • square:正方形線帽
  4. lineJoin 線條轉(zhuǎn)彎處的線帽
    • 值類型同上昭灵。

插件編寫

搭架子

首先我們先搭起一個(gè)大的架子吠裆。其中需要通過props傳入的值均先由data替代伐谈。

<template>
  <div class="hand-writing">
      <canvas 
        ref="writingCanvas" 
        class="writing-box" 
        :width="canvasWidth" 
        :height="canvasHeight" 
        @touchstart="onStart"
        @touchmove="onMove" 
        @touchend="onEnd">
      </canvas>
      <div class="btn-box">
        <div class="btn btn-clear" @click="onClear">清屏</div>
        <div class="btn btn-generate" @click="onGenerate">生成</div>
      </div>
  </div>
</template>

<script>

export default {
  name: 'HandWriting',
  data: function() {
    return {
      // 畫板坐標(biāo)
      offsetWidth: 0,
      offsetHeight: 0,
      // 畫板寬度
      canvasWidth: '',
      // 畫板高度
      canvasHeight: '',
      // 線條寬度
      lineWidth: 10,
      // 線條顏色
      lineColor: '#000',
    };
  },
  mounted() {
      this.init()
  },
  methods: {
      // 畫板初始化
      init () {},
      // 開始觸摸
      onStart (e) {},
      // 移動(dòng)
      onMove (e) {},
      // 停止觸摸
      onEnd (e) {},
      // 點(diǎn)擊取消
      onClear () {},
      // 點(diǎn)擊確認(rèn)
      onGenerate () {},
  },
}
</script>

<style lang="css" scoped>
.hand-writing {
    width: 100%;
    height: 100%;
    background: #fff;
}

.writing-box {
    display: block;
    margin: 0 auto;
    width: 100%;
    height: 80%;
    background: #ccc;
}
.btn-box {
  margin: 0 auto;
  width: 300px;
  height: 20%;
}
.btn {
  box-sizing: border-box;
  margin: 20px 25px;
  display: inline-block;
  width: 100px;
  height: 50px;
  border: 1px solid #1890ff;
  border-radius: 10px;
  background: #1890ff;
  color: #fff;
  text-align: center;
  line-height: 50px;
}

.btn:active {
  background: #fff;
  color: #000;
}
</style>

這樣一個(gè)架子就搭完了,效果如下圖所示试疙。

架子效果圖

初始化

然后我們開始進(jìn)入整體诵棵,首先需要獲取canvas的context對(duì)象。在init()方法中添加如下代碼

init () {
  // 獲取canvas
  const canvas = this.$refs.writingCanvas
  // 顯式的寬高賦值
  this.canvasWidth = canvas.offsetWidth
  this.canvasHeight = canvas.offsetHeight
  // 獲取context對(duì)象
  this.ctx = canvas.getContext('2d')
},
onStart

獲取到了context對(duì)象就可以開始畫東西了祝旷。在onStart()方法中添加如下代碼

onStart (e) {
  // 獲取畫板相對(duì)于屏幕的偏移量履澳,即左上角的坐標(biāo)
  this.offsetLeft = e.target.offsetLeft
  this.offsetTop = e.target.offsetTop
  // 獲取點(diǎn)擊點(diǎn)的坐標(biāo)(實(shí)際坐標(biāo) = 點(diǎn)擊點(diǎn)相對(duì)于屏幕的坐標(biāo) - 畫板相對(duì)于屏幕的坐標(biāo))
  let x = e.touches[0].clientX - this.offsetLeft
  let y = e.touches[0].clientY - this.offsetTop
  // 開始繪制
  this.ctx.beginPath()
  // 設(shè)置線條屬性
  this.ctx.lineWidth = this.lineWidth
  this.ctx.strokeStyle = this.lineColor
  this.ctx.lineCap = 'round'
  this.ctx.lineJoin = 'round'
  // 繪制點(diǎn)擊點(diǎn)
  this.ctx.lineTo(x, y)
  this.ctx.stroke()
},
onMove

現(xiàn)在調(diào)試這個(gè)DEMO,就可以發(fā)現(xiàn)已經(jīng)可以在畫板中點(diǎn)擊畫點(diǎn)了怀跛。下面開始讓這個(gè)點(diǎn)移動(dòng)起來距贷。在onMove()方法中添加如下代碼

onMove (e) {
  // 獲取點(diǎn)擊點(diǎn)的坐標(biāo)
  let x = e.touches[0].clientX - this.offsetLeft
  let y = e.touches[0].clientY - this.offsetTop
  // 繪制
  this.ctx.lineTo(x, y)
  this.ctx.stroke()
},
onEnd

現(xiàn)在再調(diào)試,已經(jīng)可以正常的畫線了吻谋,但是有始有終忠蝗,當(dāng)我們停止手指觸摸時(shí),應(yīng)該關(guān)閉路徑繪制漓拾。在onEnd()方法中添加如下代碼

onEnd () {
  // 停止繪制
  this.ctx.closePath()
},
onClear

到此畫圖的部分已經(jīng)做完了阁最,下面我們來實(shí)現(xiàn)清除畫板功能和生成圖片功能。首先是清除功能骇两。在onClear()方法中添加如下代碼

onClear () {
  // 清空畫板
  this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
},
onGenerate

然后來添加生成圖片的功能速种。在onGenerate()方法中添加如下代碼

onGenerate () {
  const filePath = this.$refs.writingCanvas.toDataURL()
  console.log(filePath)
},
圖片顯示

點(diǎn)擊生成按鈕就可以在控制臺(tái)看到打印的png類型的圖片的base64地址。現(xiàn)在讓我們將其顯示在屏幕上低千。修改整體代碼如下

<template>
  <div class="hand-writing">
      <img v-if="filePath" :src="filePath" alt="">
      <canvas 
        v-else
        ref="writingCanvas" 
        class="writing-box" 
        :width="canvasWidth" 
        :height="canvasHeight" 
        @touchstart="onStart"
        @touchmove="onMove" 
        @touchend="onEnd">
      </canvas>
      <div class="btn-box">
        <div class="btn btn-clear" @click="onClear">清屏</div>
        <div class="btn btn-generate" @click="onGenerate">生成</div>
      </div>
  </div>
</template>

<script>

export default {
  name: 'HandWriting',
  data: function() {
    return {
      ...
      
      // 圖片地址
      filePath: '',
    };
  },
  mounted() {
      this.init()
  },
  methods: {
      
      ...

      // 點(diǎn)擊取消
      onClear () {
        this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
        this.filePath = ''
        // 清空filePath會(huì)造成DOM更新哟旗,而DOM更新會(huì)有延遲,所以放在nextTick中在DOM更新完后執(zhí)行
        // 清空filePath會(huì)讓canvas重新生成栋操,所以需要重新初始化
        this.$nextTick(() => {
          this.init()
        })
      },
      // 點(diǎn)擊確認(rèn)
      onGenerate () {
        // 當(dāng)生成之后再點(diǎn)擊將無效
        this.filePath = this.filePath ? this.filePath : this.$refs.writingCanvas.toDataURL()
      },
  },
}
</script>

代碼優(yōu)化

可以看到剛剛寫的代碼非常冗余闸餐,讓我們優(yōu)化一下》剑可以將所有的繪制提取出來舍沙,如下

// 將onStart 和 onMove中的公共代碼提取出來
handleDraw (e) {
  // 獲取點(diǎn)擊點(diǎn)的坐標(biāo)
  let x = e.touches[0].clientX - this.offsetLeft
  let y = e.touches[0].clientY - this.offsetTop
  // 繪制
  this.ctx.lineTo(x, y)
  this.ctx.stroke()
},

作為一個(gè)插件我們需要提供一些暴露給外部的方法,變量剔宪,以及需要接收一些變量拂铡,更改如下

export default {
  name: 'HandWriting',
  props: {
    path: {
      type: String,
      default: '',
    }
  },
  
  ...

  methods: {
      // 畫板初始化
      init () {
        
        ...

        if (this.path !== '') {
          this.filePath = this.path
        }
      },
      
      ...

      // 點(diǎn)擊取消
      onClear () {

        ...

        // 清除的回調(diào)
        this.$emit('onClear')
      },
      // 點(diǎn)擊確認(rèn)
      onGenerate () {
        if(this.filePath) {
          this.filePath = this.filePath
        } else {
          this.filePath = this.$refs.writingCanvas.toDataURL()
          // 生成圖片的回調(diào)
        this.$emit('onComplete', this.filePath)
        }
      },
  },
}

這里props只接收了圖片,當(dāng)然也可以接收canvas的寬度葱绒、高度等感帅。方法都一樣,就不再贅述地淀。

在線預(yù)覽&源碼

預(yù)覽請(qǐng)打開控制臺(tái)在手機(jī)模式下運(yùn)行失球。筆畫位置有誤差請(qǐng)刷新瀏覽器。
DEMO

源碼:GitHub

功能擴(kuò)展

此章節(jié)開始所有的ui及樣式將使用Ant Design Vue。之前的代碼無需更改实苞,并無沖突豺撑。

更改畫筆粗細(xì)

更改筆畫粗細(xì)首先需要一個(gè)調(diào)節(jié)筆畫粗細(xì)的組件。這里使用了antd的氣泡卡片作為調(diào)節(jié)的容器黔牵。使用滑動(dòng)輸入條調(diào)節(jié)粗細(xì)聪轿。在文件中添加、更改如下代碼

<template>
  <div class="hand-writing">
    <img v-if="filePath" :src="filePath" alt="" />
    <canvas
      v-else
      ref="writingCanvas"
      class="writing-box"
      :width="canvasWidth"
      :height="canvasHeight"
      @touchstart="onStart"
      @touchmove="onMove"
      @touchend="onEnd"
    >
    </canvas>
    <div class="btn-box">
      <!-- 添加開始 -->
      <!-- 調(diào)節(jié)彈框 -->
      <a-popover v-model="adjustVisible">
        <template slot="title">
          <div class="adjust-header">
            <span>調(diào)節(jié)筆畫</span>
            <a-icon type="close-circle" @click="adjustVisible = false" />
          </div>
        </template>
        <template slot="content">
          <div class="adjust-content">
            <a-row>
              <a-col :span="4">線條粗細(xì)</a-col>
              <a-col :span="12">
                <a-slider :min="1" :max="20" v-model="lineWidth" />
              </a-col>
            </a-row>
          </div>
        </template>
        <a-button
          type="primary"
          class="btn btn-adjust"
          @click="adjustVisible = true"
        >
          調(diào)節(jié)筆畫
        </a-button>
      </a-popover>
      <!-- 添加結(jié)束 -->
      <a-button type="primary" class="btn btn-clear" @click="onClear"
        >清屏</a-button
      >
      <a-button type="primary" class="btn btn-generate" @click="onGenerate"
        >生成</a-button
      >
    </div>
  </div>
</template>

<script>
export default {
  name: "HandWriting",
  props: {
    path: {
      type: String,
      default: ""
    }
  },
  data: function() {
    return {
      ...

      // 添加卡片隱藏控制變量
      adjustVisible: false
    };
  },
  ...
};
</script>

<style lang="css" scoped>

...

.btn-box {
  margin: 0 auto;
  padding-top: 10px;
  height: 20%;
}
.btn {
  margin: 0 15px;
  width: 100px;
  height: 50px;
  line-height: 50px;
}

.adjust-header {
    display: flex;
}

.adjust-header span {
    flex: 1;
}

.adjust-header .anticon{
    flex: 1;
    text-align: right;
    line-height: 21px;
}
</style>

更改之后猾浦,點(diǎn)擊調(diào)節(jié)筆畫陆错,就可以在彈框中調(diào)節(jié)粗細(xì)了。

更改畫筆顏色

這里使用vue-color調(diào)色板來調(diào)節(jié)顏色金赦。

首先安裝vue-color危号。

npm i vue-color
or
yarn add vue-color

然后在文件中引入,這里使用的Chrome樣式的調(diào)色板素邪。

import { Chrome } from 'vue-color'

...

data() {
  return {
    pickerColor: {},
  }
},
components: {
  "chrome-picker": Chrome
},

...

<chrome-picker v-model="pickerColor" />

直接通過這個(gè)插件獲取到的值是一個(gè)對(duì)象,我們需要處理這個(gè)對(duì)象猪半。添加如下代碼兔朦,當(dāng)調(diào)色板顏色發(fā)生變化時(shí),改變線條顏色磨确。

watch: {
  pickerColor: function(now) {
    this.lineColor = now.hex8
  }
},

添加完成之后沽甥,就可以開始愉快的更改畫筆顏色,但是有點(diǎn)丑乏奥,讓我們稍微修改一下樣式摆舟。在文件中添加一些代碼。

在template中邓了,添加以下代碼恨诱。其中整個(gè)線條的顏色的選擇器放在了一個(gè)浮動(dòng)的div里面,沒有繼續(xù)使用popover是因?yàn)槠瑑?nèi)層的關(guān)閉會(huì)造成外層同時(shí)關(guān)閉照宝。所以自己寫了一個(gè)類似的。

<a-popover
  v-model="adjustVisible"
  :arrowPointAtCenter="true"
>
  <template slot="title">
    <div class="adjust-header">
      <span>調(diào)節(jié)筆畫</span>
      <a-icon type="close-circle" @click="adjustVisible = false" />
    </div>
  </template>
  <template slot="content">
    <div class="adjust-content">
      <a-row>
        <a-col :span="8">線條粗細(xì)</a-col>
        <a-col :span="16">
          <a-slider :min="1" :max="20" v-model="lineWidth" />
        </a-col>
      </a-row>
      <a-row>
        <a-col :span="8">線條顏色</a-col>
        <a-col :span="16">
          <div
            class="color-body"
            :style="{ background: lineColor }"
            @click="colorVisible = true"
          ></div>
          <div class="picker-box" v-if="colorVisible">
            <a-icon
              class="picker-cancel"
              type="close-circle"
              @click="colorVisible = false"
            />
            <chrome-picker v-model="pickerColor" />
          </div>
        </a-col>
      </a-row>
    </div>
  </template>
  <a-button
    type="primary"
    class="btn btn-adjust"
    @click="adjustVisible = true"
  >
    調(diào)節(jié)筆畫
  </a-button>
</a-popover>

因?yàn)閜opover是直接掛載在 body 上的句葵,所以我們還需要將其掛載在當(dāng)前組件的根節(jié)點(diǎn)上才可以改變器樣式厕鹃。首先在根節(jié)點(diǎn)上注冊(cè)一個(gè)ref。

<div ref="box" class="hand-writing">
  ...
</div>

然后寫一個(gè)方法乍丈,返回注冊(cè)好的節(jié)點(diǎn)

handleGetContainer() {
  return this.$refs.box;
}

然后在popover上使用此方法剂碴,就可以更改popover的樣式了。

<a-popover
  :getPopupContainer="handleGetContainer"
>
  ...
</a-popover>

添加如下樣式代碼轻专。


.adjust-header {
    display: flex;
    width: 188px;
}

.adjust-header span {
    flex: 1;
}

.adjust-header .anticon{
    flex: 1;
    text-align: right;
    line-height: 21px;
}
.ant-row {
    line-height: 36px;
}
.color-body {
    margin-left: 5px;
    vertical-align: sub;
    display: inline-block;
    width: 15px;
    height: 15px;
    border-radius: 50%;
    background: #000;
}
.picker-box {
  position: absolute;
  padding: 10px;
  background: #fff;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
  bottom: 30px;
  left: 10px;
}
.picker-box .picker-cancel {
  float: right;
  height: 20px;
  line-height: 20px;
}

.picker-box .vc-chrome {
  box-shadow: 0 0 0;
}

然后看看效果就會(huì)發(fā)現(xiàn)好很多了忆矛,雖然還是有點(diǎn)丑,就不再繼續(xù)優(yōu)化了请垛。

橡皮檫功能

橡皮檫的實(shí)現(xiàn)類似于清除功能洪碳,不同的是递览,清除是擦除整個(gè)屏幕,而橡皮檫是擦除某一部分瞳腌。一樣的需要用到 onStart绞铃、 onMove、 onEnd 三個(gè)方法嫂侍。所以我們需要一個(gè)新變量來存儲(chǔ)當(dāng)前鼠標(biāo)(也就是手指儿捧?我也不知道叫什么合適。挑宠。菲盾。)的狀態(tài),判斷當(dāng)前究竟是畫筆還是橡皮擦各淀。不同的狀態(tài)執(zhí)行不同的方法懒鉴。
首先在data中添加一個(gè)變量 mouseStatus: "brush"
然后在template中添加一個(gè)按鈕控制其變化

<a-button
  type="primary"
  class="btn btn-switch"
  @click="mouseStatus = mouseStatus === 'brush' ? 'eraser' : 'brush'"
>
  切換為{{ mouseStatus === "brush" ? "橡皮檫" : "畫筆" }}
</a-button>

代碼優(yōu)化

然后為了避免代碼的冗余碎浇,我們需要對(duì)代碼進(jìn)行一次大換血临谱,首先定義以下幾個(gè)方法。

/**
 * 
 * 通過mouseStatus的值以及鼠標(biāo)當(dāng)前的動(dòng)作階段來判斷該執(zhí)行哪個(gè)方法
 * e: event
 * type: 鼠標(biāo)當(dāng)前的動(dòng)作階段(start奴璃、move悉默、end)
 *
 */
handleSelectTouch(e, type) {}
/**
 * 
 * 畫筆畫圖三步
 *
 */
onBrushStart(e) {}
onBrushMove(e) {}
onBrushEnd() {}
/**
 * 
 * 橡皮檫三步
 *
 */
onEraserStart(e) {}
onEraserMove(e) {}
onEraserEnd() {}
/**
 * 
 * 擦除方法,擦除以點(diǎn)擊點(diǎn)為圓心苟穆,畫筆寬度為直徑的圓
 * x: 點(diǎn)擊點(diǎn)x坐標(biāo)
 * y: 點(diǎn)擊點(diǎn)y坐標(biāo)
 * radius: 半徑
 *
 */
clearArc(x, y, radius) {}
/**
 * 
 * 擦除輔助方法抄课,將擦出點(diǎn)連接起來
 * e: event
 * radius: 半徑
 *
 */
clearLine(e, radius) {}

然后我們需要將以下幾個(gè)方法的內(nèi)容移至新方法內(nèi),并刪除雳旅。

onStart() -> onBrushStart()
onMove() -> onBrushMove()
onEnd() -> onBrushEnd()

更改部分html代碼如下

<!-- 將判斷是畫筆還是橡皮檫的代碼全部放在一個(gè)方法里跟磨,方便處理 -->
<canvas
  ...
  @touchstart="handleSelectTouch($event, 'start')"
  @touchmove="handleSelectTouch($event, 'move')"
  @touchend="handleSelectTouch($event, 'end')"
>
</canvas>

添加判斷畫筆屬性的代碼

handleSelectTouch(e, type) {
  let _data = this.getInitialCapital(this.mouseStatus);
  switch (type) {
    case "start":
      this[`on${_data}Start`](e); // 這種調(diào)用方法看習(xí)慣了就好了
      break;
    case "move":
      this[`on${_data}Move`](e);
      break;
    case "end":
      this[`on${_data}End`](e);
      break;
    default:
      break;
  }
},
// 將字符串轉(zhuǎn)換為首字母大寫的形式
getInitialCapital(val) {
  return val.replace(/\S/, item => item.toUpperCase());
}

這時(shí)候在橡皮檫的三步方法里添加打印代碼后,通過改變畫筆屬性就可以看到不同的效果了攒盈。接下來進(jìn)入擦除的正題吱晒。

擦除圓形

在 canvas 的API里面我們發(fā)現(xiàn)擦除的方法只有一個(gè) clearRect,而且此方法只能擦除一個(gè)矩形沦童,而不能擦除其他圖形仑濒,這不符合我們的想法。我們只能通過其他方法曲線救國(guó)偷遗。也就是 clip墩瞳,clip 是 Canvas 2D API 將當(dāng)前創(chuàng)建的路徑設(shè)置為當(dāng)前剪切路徑的方法。也就是說我們可以先畫一個(gè)圓氏豌,然后將這個(gè)圓設(shè)置為剪切路徑喉酌,然后使用 clearReact 方法將其擦除。添加如下代碼

onEraserStart(e) {
  this.offsetLeft = e.target.offsetLeft;
  this.offsetTop = e.target.offsetTop;
  this.c1px = e.touches[0].clientX - this.offsetLeft;
  this.c1py = e.touches[0].clientY - this.offsetTop;
  this.clearArc(this.c1px, this.c1py, this.lineWidth / 2);
},
onEraserMove(e) {
  this.offsetLeft = e.target.offsetLeft;
  this.offsetTop = e.target.offsetTop;
  this.c1px = e.touches[0].clientX - this.offsetLeft;
  this.c1py = e.touches[0].clientY - this.offsetTop;
  this.clearArc(this.c1px, this.c1py, this.lineWidth / 2);
},
onEraserEnd() {
  console.log("end");
},
clearArc(x, y, radius) {
  this.ctx.save();
  this.ctx.beginPath();
  // 畫圓,以點(diǎn)擊點(diǎn)為圓心坐標(biāo)泪电,線條的寬度為直徑畫圓
  this.ctx.arc(x, y, radius, 0, 2 * Math.PI);
  // 設(shè)置為剪切路徑
  this.ctx.clip();
  // 擦除
  this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
  this.ctx.restore();
},

這時(shí)就可以正常的使用橡皮擦了般妙,但是當(dāng)鼠標(biāo)移動(dòng)快一點(diǎn)的時(shí)候就會(huì)發(fā)現(xiàn)擦除的路徑是不連續(xù)的,造成這種問題的原因是相速,快速移動(dòng)的時(shí)候碟渺,只會(huì)畫一個(gè)圓,但是圓與圓之間的路徑是不會(huì)被畫上的突诬。下面苫拍,讓我們來優(yōu)化一下這個(gè)橡皮擦。

優(yōu)化橡皮擦

因?yàn)楫嫻P的寬度在使用的途中是固定的旺隙,也就是說我們?cè)诋嬄窂降臅r(shí)候只需要將兩個(gè)圓之間的矩形也畫上就可以了绒极。如下圖所示。

矩形路徑

畫這個(gè)矩形我們需要知道矩形的四個(gè)頂點(diǎn)蔬捷,但是目前我們只知道其中兩條邊的中點(diǎn)垄提,也就是兩個(gè)圓的圓心,我們需要計(jì)算以下這四個(gè)頂點(diǎn)周拐。
首先铡俐,我們?cè)谏蠄D中添加幾條輔助線。如下圖所示速妖。

路徑輔助線

可以看出我們需要得出四個(gè)頂點(diǎn)的坐標(biāo)就必須先求得頂點(diǎn)與圓心的所在的直角三角形的兩條直角邊的長(zhǎng)度。非常簡(jiǎn)單的相似三角形聪黎,有多種方法可以解決罕容,這里使用的是三角函數(shù)。方法如下

let sinX = Math.sin(Math.atan((y2 - y1) / (x2 - x1)))
let cosY = Math.cos(Math.atan((y2 - y1) / (x2 - x1)))

借助上面的圖稿饰,可以很輕松的理解這兩個(gè)計(jì)算式锦秒。得到了兩條直角邊的長(zhǎng)度,剩下的就是計(jì)算四個(gè)頂點(diǎn)坐標(biāo)喉镰,然后畫矩形了旅择。添加及更改如下代碼

onEraserStart(e) {
  this.offsetLeft = e.target.offsetLeft;
  this.offsetTop = e.target.offsetTop;
  this.c1px = e.touches[0].clientX - this.offsetLeft;
  this.c1py = e.touches[0].clientY - this.offsetTop;
  // 在矩形起點(diǎn)畫圓
  this.clearArc(this.c1px, this.c1py, this.lineWidth / 2);
},
onEraserMove(e) {
  this.clearLine(e, this.lineWidth / 2);
},
onEraserEnd() {
  console.log("end");
},
clearArc(x, y, radius) {
  this.ctx.save();
  this.ctx.beginPath();
  this.ctx.arc(x, y, radius, 0, 2 * Math.PI);
  this.ctx.clip();
  this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
  this.ctx.restore();
},
clearLine(e, radius) {
  this.offsetLeft = e.target.offsetLeft;
  this.offsetTop = e.target.offsetTop;
  let endX = e.touches[0].clientX - this.offsetLeft;
  let endY = e.touches[0].clientY - this.offsetTop;
  // 在矩形重點(diǎn)畫圓
  this.clearArc(endX, endY, radius);

  // 計(jì)算輔助邊長(zhǎng)
  let sinX =
    radius * Math.sin(Math.atan((endY - this.c1py) / (endX - this.c1px)));
  let cosY =
    radius * Math.cos(Math.atan((endY - this.c1py) / (endX - this.c1px)));
  this.ctx.save();
  // 畫矩形
  this.ctx.beginPath();
  this.ctx.moveTo(this.c1px - sinX, this.c1py + cosY);
  this.ctx.lineTo(this.c1px + sinX, this.c1py - cosY);
  this.ctx.lineTo(endX + sinX, endY - cosY);
  this.ctx.lineTo(endX - sinX, endY + cosY);
  this.ctx.closePath();
  this.ctx.clip();
  this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
  this.ctx.restore();
  this.c1px = endX;
  this.c1py = endY;
},

細(xì)心的可以看到在方法中有這樣兩句代碼 this.ctx.save(); 以及 this.ctx.restore();,第一句代碼是保存當(dāng)前的繪圖環(huán)境侣姆,第二句是重置當(dāng)前的繪圖環(huán)境生真,如果不加這兩句的話,在使用完橡皮擦后捺宗,繪圖會(huì)被限制在剪切范圍內(nèi)柱蟀。

撤銷&還原

撤銷和還原應(yīng)該算是一個(gè)畫圖板的基本功能,這里就來實(shí)現(xiàn)一下蚜厉。主要思路是定義一個(gè)棧(就是數(shù)組)來放置每一次更改畫板的狀態(tài)长已,然后通過一個(gè)指針(就是數(shù)組下標(biāo))來確定當(dāng)前所在的狀態(tài)。撤銷操作就是將指針移至上一個(gè)狀態(tài)(數(shù)組下標(biāo)減一),還原操作就是將指針移至下一個(gè)狀態(tài)(數(shù)組下標(biāo)加一)∈跷停現(xiàn)在來實(shí)現(xiàn)這個(gè)思路康聂,首先我們需要兩個(gè)按鍵來幫助我們實(shí)現(xiàn)這兩個(gè)功能。添加如下代碼胞四。

<a-button type="primary" class="btn btn-undo" @click="onUndo">
  撤銷
</a-button>
<a-button type="primary" class="btn btn-reduction" @click="onReduction">
  還原
</a-button>
// 撤銷
onUndo() {},
// 還原
onReduction() {},

然后在data中定義兩個(gè)輔助變量恬汁。

boardData: [],
boardStatus: 0

然后定義一個(gè)方法來執(zhí)行入棧操作(向數(shù)組push值)。

setBoardStatus(data) {
  this.boardData.push(data);
  this.boardStatus += 1;
}

然后在 init 方法中對(duì)其初始化撬讽。

init() {
  // 這里賦值為 -1 是因?yàn)橄旅娴娜霔2僮鲿?huì)對(duì)boardStatus進(jìn)行 +1 處理蕊连,這里為了保證一致性
  this.boardStatus = -1;
  this.boardData = [];
  if (this.path !== "") {
    this.filePath = this.path;
  } else {
    // 如果沒有默認(rèn)圖片再 push 默認(rèn)狀態(tài)
    this.setBoardStatus(
      this.ctx.getImageData(0, 0, this.canvasWidth, this.canvasHeight)
    );
  }
},

然后我們需要在每個(gè)改變畫板狀態(tài)的動(dòng)作執(zhí)行完后執(zhí)行入棧操作。

onBrushEnd() {
  this.ctx.closePath();
  this.setBoardStatus(
    this.ctx.getImageData(0, 0, this.canvasWidth, this.canvasHeight)
  );
},
onEraserEnd() {
  this.setBoardStatus(
    this.ctx.getImageData(0, 0, this.canvasWidth, this.canvasHeight)
  );
},

接下來我們開始實(shí)現(xiàn)撤銷功能游昼,就是將當(dāng)前的畫板置為上一個(gè)狀態(tài)甘苍。在 onUndo() 中添加如下代碼

onUndo() {
  this.boardStatus--;
  // 判斷上一個(gè)狀態(tài)是否存在,存在則將當(dāng)前畫板置為上一個(gè)狀態(tài)烘豌,否則提示錯(cuò)誤载庭,下標(biāo)歸位。
  if (this.boardStatus >= 0) {
    this.ctx.putImageData(this.boardData[this.boardStatus], 0, 0);
  } else {
    this.boardStatus++;
    this.$message.info("已經(jīng)是第一步了");
  }
},

還原功能類似與撤銷功能,就是將當(dāng)前的畫板置為下一個(gè)狀態(tài)。在 onReduction() 中添加如下代碼

onReduction() {
  this.boardStatus++;
  // 判斷下一個(gè)狀態(tài)是否存在毕匀,存在則將當(dāng)前畫板置為下一個(gè)狀態(tài)侨嘀,否則提示錯(cuò)誤,下標(biāo)歸位擦秽。
  if (this.boardStatus < this.boardData.length) {
    this.ctx.putImageData(this.boardData[this.boardStatus], 0, 0);
  } else {
    this.boardStatus--;
    this.$message.info("已經(jīng)是最新的了");
  }
},

至此撤銷還原功能就完全實(shí)現(xiàn)了,有一個(gè)缺點(diǎn)就是當(dāng)動(dòng)作執(zhí)行的比較多了之后會(huì)比較占內(nèi)存∥剿桑可以考慮使用 history 實(shí)現(xiàn)同樣的功能。

源碼&在線預(yù)覽

預(yù)覽請(qǐng)打開控制臺(tái)在手機(jī)模式下運(yùn)行践剂。筆畫位置有誤差請(qǐng)刷新瀏覽器鬼譬。
DEMO

源碼:GitHub

功能菜單樣式優(yōu)化

看著慘不忍睹的界面是時(shí)候優(yōu)化一下樣式了。目前的想法是做一個(gè)收縮可移動(dòng)的菜單逊脯。效果如圖所示优质。

效果圖1

效果圖2

首先說一下實(shí)現(xiàn)這個(gè)所需要掌握的知識(shí)點(diǎn)。包括:touch相關(guān)三個(gè)方法军洼、transform與transition巩螃。(emmmm,好像也沒有什么高深的技術(shù)匕争。)

菜單控制按鈕的實(shí)現(xiàn)

這是一個(gè)在移動(dòng)端比較常見的菜單按鍵牺六。這里自己實(shí)現(xiàn)一下。

菜單按鈕

首先我們需要在上一章節(jié)的基礎(chǔ)上更改一下HTML的結(jié)構(gòu)汗捡。如下

<template>
  <div ref="box" class="hand-writing">
    <div v-if="!app" style="height: 100%">
      <img v-if="filePath" :src="filePath" alt="" />
      <canvas></canvas>
      <div
        class="menu-box"
        :style="{ top: `${menuTop}px`, left: `${menuLeft}px` }"
        @touchstart="onMenuStart($event)"
        @touchmove="onMenuMove($event)"
        @touchend="onMenuEnd($event)"
      >
        <div :class="{ 'list-show': menuShow }" class="menu-list">
          <div
            :class="{ 'btn-checked': menuShow }"
            class="menu-btn"
            @click="onClickMenuBtn"
          >
            <span class="menu-btn-item"></span>
            <span class="menu-btn-item"></span>
            <span class="menu-btn-item"></span>
          </div>
          <div class="list-item item-checked">1</div>
          <div class="list-item">2</div>
          <div class="list-item">3</div>
          <div class="list-item">4</div>
        </div>
      </div>
    </div>
    <div v-else>暫不支持QQ瀏覽器,請(qǐng)選擇其他瀏覽器打開淑际。</div>
  </div>
</template>

在canvas同層結(jié)構(gòu)外添加了一層div畏纲,是為了將QQ瀏覽器屏蔽掉。你也可以去掉這一層春缕。然后刪除了之前所有的功能按鈕盗胀,添加了一個(gè)菜單控制按鈕。菜單按鈕的動(dòng)態(tài)style是為了方便控制菜單的位置锄贼。然后我們添加及更改一下樣式票灰。

.menu-box {
    position: absolute;
    top: 0;
    left: 0;
    z-index: 999;
    width: auto;
    height: auto;
    background: #fff;
}

.menu-box div.list-show {
    height: 100%;
    padding-bottom: 10px;
}

.menu-box .menu-list {
    padding-bottom: 10px;
    width: 35px;
    height: 27px;
    border: 1px solid #000;
    border-radius: 5px;
    text-align: center;
    overflow: hidden;
}

.menu-box .menu-list .menu-btn {
    display: inline-block;
    width: 20px;
    height: 100%;
    line-height: 3px;
}
.menu-box .menu-list .menu-btn .menu-btn-item {
    display: inline-block;
    width: 20px;
    height: 2px;
    background: #666;
    border-radius: 5px;
    transition: transform .5s;
}

.btn-checked .menu-btn-item:first-child {
    transform: translateY(7px) rotate(45deg);
}
.menu-box .menu-list .btn-checked .menu-btn-item:nth-child(2) {
    display: none;
}
.btn-checked .menu-btn-item:last-child {
    transform: rotate(-45deg);
}
.menu-box .menu-list .list-item {
    display: inline-block;
    width: 25px;
    height: 25px;
    border-radius: 5px;
    margin-top: 5px;
}
.item-checked {
    border: 1px solid #000;
}

樣式很簡(jiǎn)單,就不多介紹了宅荤,如果你對(duì)transform和transition不太了解屑迂,可以關(guān)注一下我的下一篇博客。

然后我們定義一個(gè)控制菜單開關(guān)的方法冯键。

onClickMenuBtn() {
  this.menuShow = !this.menuShow;
},

到了這里其實(shí)大部分都已經(jīng)完成了惹盼,剩下的只有div的移動(dòng),而在前兩個(gè)章節(jié)惫确,我們都在于此打交道手报,所以是很簡(jiǎn)單了。我們繼續(xù)改化。

菜單移動(dòng)

在data中添加幾個(gè)輔助變量掩蛤。

data() {
  return {
    // 菜單相關(guān)
      menuShow: false, // 控制菜單的顯示隱藏
      menuTop: 0, // 菜單的top
      menuLeft: 0, // 菜單的left
      menuX: 0, // 輔助坐標(biāo)X
      menuY: 0 // 輔助坐標(biāo)Y
  }
}

然后我們添加一下控制移動(dòng)的三個(gè)方法。

onMenuStart(e) {
  // 將點(diǎn)擊點(diǎn)存起來
  this.menuX = e.touches[0].clientX;
  this.menuY = e.touches[0].clientY;
},
onMenuMove(e) {
  // 獲取當(dāng)前點(diǎn)擊點(diǎn)
  let x = e.touches[0].clientX;
  let y = e.touches[0].clientY;
  // 當(dāng)前點(diǎn)和在onMenuStart中保存的點(diǎn)的距離就是菜單移動(dòng)的距離
  this.menuTop += y - this.menuY;
  this.menuLeft += x - this.menuX;
  // 將當(dāng)前點(diǎn)存起來
  this.menuX = x;
  this.menuY = y;
},
onMenuEnd() {
  //   console.log("object");
},

這時(shí)候就可以進(jìn)行正常的移動(dòng)的陈肛,但是一不小心移出去了怎么辦揍鸟?讓我們添加一個(gè)邊界判斷的方法,讓菜單只在可視內(nèi)容區(qū)域移動(dòng)句旱。

// 傳入當(dāng)前的left阳藻、top,如果超出邊界就處理一下前翎。
handleBorderJudgment(left, top) {
  if (left < 0) {
    left = 0;
  } else if (left > this.canvasWidth - 35) { // 這里減去菜單的寬度為了避免菜單寬度造成的影響
    left = this.canvasWidth - 35;
  }
  if (top < 0) {
    top = 0;
  } else if (top > this.canvasHeight - 27) { // 這里減去菜單的高度為了避免菜單高度造成的影響
    top = this.canvasHeight - 27;
  }
  return {
    left,
    top
  };
},

然后我們只需要在移動(dòng)過程中使用這個(gè)方法就可以了稚配。

onMenuMove(e) {
  let x = e.touches[0].clientX;
  let y = e.touches[0].clientY;
  this.menuTop += y - this.menuY;
  this.menuLeft += x - this.menuX;
  // 將計(jì)算出來的值判斷一下然后重新賦值
  let _data = this.handleBorderJudgment(this.menuLeft, this.menuTop);
  this.menuTop = _data.top;
  this.menuLeft = _data.left;
  this.menuX = x;
  this.menuY = y;
},

這時(shí)候移動(dòng)到邊界會(huì)發(fā)現(xiàn)還有一個(gè)bug畅涂,就是打開菜單的時(shí)候會(huì)撐開內(nèi)容區(qū)域港华,這不是我們想要的,所以在 onMenuMove 第一行添加一行代碼 this.menuShow = false;午衰,當(dāng)移動(dòng)時(shí)立宜,我們手動(dòng)讓他關(guān)閉就可以了。

菜單功能添加

菜單使用的都是icon臊岸,但是ant的圖標(biāo)不多橙数,所以我在阿里圖標(biāo)庫(kù)創(chuàng)建了一個(gè)小應(yīng)用,你可以直接拿去用帅戒。

首先灯帮,按照ant的介紹崖技,添加如下代碼

import { Icon } from 'ant-design-vue';

const IconFont = Icon.createFromIconfontCN({
  scriptUrl: '//at.alicdn.com/t/font_1372570_awwwx5suzr.js',
})
export default {
  components: {
    IconFont,
  }
}

然后更改HTML部分代碼如下

<div
  :class="{ 'item-checked': mouseStatus === 'brush' }"
  class="list-item"
>
  <a-popover
    v-model="adjustVisible"
    placement="right"
    :arrowPointAtCenter="true"
    :getPopupContainer="handleGetContainer"
  >
    <template slot="title">
      <div class="adjust-header">
        <span>調(diào)節(jié)筆畫</span>
        <a-icon type="close-circle" @click="adjustVisible = false" />
      </div>
    </template>
    <template slot="content">
      <div class="adjust-content">
        <a-row>
          <a-col :span="8">線條粗細(xì)</a-col>
          <a-col :span="16">
            <a-slider :min="1" :max="20" v-model="lineWidth" />
          </a-col>
        </a-row>
        <a-row>
          <a-col :span="8">線條顏色</a-col>
          <a-col :span="16">
            <div
              class="color-body"
              :style="{ background: lineColor }"
              @click="colorVisible = true"
            ></div>
            <div class="picker-box" v-if="colorVisible">
              <a-icon
                class="picker-cancel"
                type="close-circle"
                @click="colorVisible = false"
              />
              <chrome-picker v-model="pickerColor" />
            </div>
          </a-col>
        </a-row>
      </div>
    </template>
    <a-icon type="edit" @click="onClickEdit" />
  </a-popover>
</div>
<div
  :class="{ 'item-checked': mouseStatus === 'eraser' }"
  class="list-item"
>
  <a-popover
    v-model="eraserVisible"
    placement="right"
    :arrowPointAtCenter="true"
    :getPopupContainer="handleGetContainer"
  >
    <template slot="title">
      <div class="adjust-header">
        <span>調(diào)節(jié)橡皮擦</span>
        <a-icon type="close-circle" @click="eraserVisible = false" />
      </div>
    </template>
    <template slot="content">
      <div class="adjust-content">
        <a-row>
          <a-col :span="8">橡皮寬度</a-col>
          <a-col :span="16">
            <a-slider :min="1" :max="50" v-model="eraserWidth" />
          </a-col>
        </a-row>
      </div>
    </template>
    <icon-font type="icon-eraser" @click="onClickEraser" />
  </a-popover>
</div>
<div class="list-item"><a-icon type="undo" @click="onUndo" /></div>
<div class="list-item">
  <a-icon type="redo" @click="onReduction" />
</div>
<div class="list-item"><a-icon type="delete" @click="onClear" /></div>
<div class="list-item">
  <a-icon type="save" @click="onGenerate" />
</div>

主要功能都是將之前章節(jié)的直接拿過來了,無需更多的更改钟哥。然后添加兩個(gè)方法迎献。

// 點(diǎn)擊畫筆
onClickEdit() {
  this.adjustVisible = true;
  this.mouseStatus = "brush";
},
// 點(diǎn)擊橡皮
onClickEraser() {
  this.eraserVisible = true;
  this.mouseStatus = "eraser";
}

至此,整個(gè)插件的功能也擴(kuò)展完成了腻贰,樣式也改了吁恍,其他的小細(xì)節(jié),就自己再改一改就好了播演。

源碼&在線預(yù)覽

預(yù)覽請(qǐng)打開控制臺(tái)在手機(jī)模式下運(yùn)行冀瓦。筆畫位置有誤差請(qǐng)刷新瀏覽器。
DEMO

源碼:GitHub

參考博文

  1. HTML5 實(shí)現(xiàn)橡皮擦的擦除效果: 博客地址
  2. 手把手教你實(shí)現(xiàn)一個(gè)canvas智繪畫板: 博客地址
  3. 使用 CSS overscroll-behavior 控制滾動(dòng)行為:自定義下拉刷新和溢出效果: 博客地址
  4. Vue Color Pickers for Sketch, Photoshop, Chrome & more: 博客地址
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末写烤,一起剝皮案震驚了整個(gè)濱河市翼闽,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌顶霞,老刑警劉巖肄程,帶你破解...
    沈念sama閱讀 219,539評(píng)論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異选浑,居然都是意外死亡蓝厌,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,594評(píng)論 3 396
  • 文/潘曉璐 我一進(jìn)店門古徒,熙熙樓的掌柜王于貴愁眉苦臉地迎上來拓提,“玉大人,你說我怎么就攤上這事隧膘〈” “怎么了?”我有些...
    開封第一講書人閱讀 165,871評(píng)論 0 356
  • 文/不壞的土叔 我叫張陵疹吃,是天一觀的道長(zhǎng)蹦疑。 經(jīng)常有香客問我,道長(zhǎng)萨驶,這世上最難降的妖魔是什么歉摧? 我笑而不...
    開封第一講書人閱讀 58,963評(píng)論 1 295
  • 正文 為了忘掉前任,我火速辦了婚禮腔呜,結(jié)果婚禮上叁温,老公的妹妹穿的比我還像新娘。我一直安慰自己核畴,他們只是感情好膝但,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,984評(píng)論 6 393
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著谤草,像睡著了一般跟束。 火紅的嫁衣襯著肌膚如雪莺奸。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,763評(píng)論 1 307
  • 那天冀宴,我揣著相機(jī)與錄音憾筏,去河邊找鬼。 笑死花鹅,一個(gè)胖子當(dāng)著我的面吹牛氧腰,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播刨肃,決...
    沈念sama閱讀 40,468評(píng)論 3 420
  • 文/蒼蘭香墨 我猛地睜開眼古拴,長(zhǎng)吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來了真友?” 一聲冷哼從身側(cè)響起黄痪,我...
    開封第一講書人閱讀 39,357評(píng)論 0 276
  • 序言:老撾萬榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎盔然,沒想到半個(gè)月后桅打,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,850評(píng)論 1 317
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡愈案,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 38,002評(píng)論 3 338
  • 正文 我和宋清朗相戀三年挺尾,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片站绪。...
    茶點(diǎn)故事閱讀 40,144評(píng)論 1 351
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡遭铺,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出恢准,到底是詐尸還是另有隱情魂挂,我是刑警寧澤,帶...
    沈念sama閱讀 35,823評(píng)論 5 346
  • 正文 年R本政府宣布馁筐,位于F島的核電站涂召,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏敏沉。R本人自食惡果不足惜果正,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,483評(píng)論 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望赦抖。 院中可真熱鬧舱卡,春花似錦辅肾、人聲如沸队萤。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,026評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)要尔。三九已至舍杜,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間赵辕,已是汗流浹背既绩。 一陣腳步聲響...
    開封第一講書人閱讀 33,150評(píng)論 1 272
  • 我被黑心中介騙來泰國(guó)打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留还惠,地道東北人饲握。 一個(gè)月前我還...
    沈念sama閱讀 48,415評(píng)論 3 373
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像蚕键,于是被迫代替她去往敵國(guó)和親救欧。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,092評(píng)論 2 355