markdown集成手绘板

 

Vue 3 Signature Pad集成到WeChat Markdown Editor


WeChat Markdown Editor | 一款高度简洁的微信 Markdown 编辑器:支持 Markdown 语法、色盘取色、多图上传、一键下载文档、自定义 CSS 样式、一键重置等特性
Vue 3 Signature Pad - A beautiful signature pad component for Vue 3.

本人是把Vue 3 Signature Pad集成到编辑菜单和编辑区右键菜单里,并增加了自定义颜色,而且支持黑暗模式, 当绘制好需要的绘画后,只要点击保存就会自动把文件上传到配置的服务器里,然后返回图片的地址到编辑区鼠标所在位置。非常方便!!233333333



集成效果
手绘配置:



绘画效果:



Vue 3 Signature Pad安装和基础使用教程


安装

npm install @selemondev/vue3-signature-pad

将其注册为本地组件

import { VueSignaturePad } from '@selemondev/vue3-signature-pad'

使用

你可以从 Icones[1] 库中获取 svgs。

<script setup lang='ts'>
import { onMounted, ref } from 'vue'
import { VueSignaturePad } from '@selemondev/vue3-signature-pad'
import type { CanvasSignature } from '@selemondev/vue3-signature-pad'

const options = ref({
  penColor'rgb(0,0,0)',
  backgroundColor'rgb(255, 255, 255)',
  maxWidth2,
  minWidth2,
})

const colors = [
  {
    color'rgb(51, 133, 255)',
  },
  {
    color'rgb(85, 255, 51)',
  },
  {
    color'rgb(255, 85, 51)',
  },
]
const signature = ref<Signature>()

function handleUndo() {
  return signature.value?.undo()
}

function handleClearCanvas() {
  return signature.value?.clearCanvas()
}

function handleSaveSignature() {
  return signature.value?.saveSignature() && alert(signature.value?.saveSignature())
}

</script>

<template>
  <div class='flex flex-col space-y-2'>
    <div class='p-4 bg-white rounded-md'>
      <div class='relative bg-gray-100 rounded-md'>
        <VueSignaturePad
          ref='signature'
          height='400px'
          width='950px'
          :max-width='options.maxWidth'
          :min-width='options.minWidth'
          :options='{
            penColor: options.penColor,
            backgroundColor: options.backgroundColor,
          }'

        />


        <div class='absolute flex flex-col space-y-2 top-3 right-4'>
          <button
            type='button'
            class='grid p-2 bg-white rounded-md shadow-md place-items-center'
            @click='handleUndo'
          >

            <svg
              xmlns='http://www.w3.org/2000/svg'
              width='20'
              height='20'
              viewBox='0 0 24 24'
            >

              <path
                fill='none'
                stroke='#000'
                stroke-linecap='round'
                stroke-linejoin='round'
                stroke-width='2'
                d='M10 8H5V3m.291 13.357a8 8 0 1 0 .188-8.991'
              />

            </svg>
          </button>
          <button
            type='button'
            class='grid p-2 bg-white rounded-md shadow-md place-items-center'
            @click='handleClearCanvas'
          >

            <svg
              xmlns='http://www.w3.org/2000/svg'
              width='20'
              height='20'
              viewBox='0 0 14 14'
            >

              <path
                fill='none'
                stroke='#000'
                stroke-linecap='round'
                stroke-linejoin='round'
                d='M11.5 8.5h-9l-.76 3.8a1 1 0 0 0 .21.83a1 1 0 0 0 .77.37h8.56a1.002 1.002 0 0 0 .77-.37a1.001 1.001 0 0 0 .21-.83zm0-3a1 1 0 0 1 1 1v2h-11v-2a1 1 0 0 1 1-1H4a1 1 0 0 0 1-1v-2a2 2 0 1 1 4 0v2a1 1 0 0 0 1 1zm-3 8V11'
              />

            </svg>
          </button>
          <button
            type='button'
            class='grid p-2 bg-white rounded-md shadow-md place-items-center'
            @click='handleSaveSignature'
          >

            <svg
              xmlns='http://www.w3.org/2000/svg'
              width='20'
              height='20'
              viewBox='0 0 24 24'
            >

              <path
                fill='#000'
                d='M21 7v14H3V3h14zm-2 .85L16.15 5H5v14h14zM12 18q1.25 0 2.125-.875T15 15t-.875-2.125T12 12t-2.125.875T9 15t.875 2.125T12 18m-6-8h9V6H6zM5 7.85V19V5z'
              />

            </svg>
          </button>
        </div>
      </div>
    </div>
    <div class='flex items-center justify-between w-full p-3 bg-white rounded-md'>
      <div>
        <h1 class='text-lg'>Choose pen color</h1>
      </div>
      <div class='flex items-center space-x-4'>
        <div v-for='color in colors' :key='color.color'>
          <button
            type='button'
            :style='{ background: color.color }'
            class='grid w-8 h-8 rounded-full place-items-center'
            @click='options.penColor = color.color'
          >

            <p v-if='options.penColor === color.color'>
              <svg
                xmlns='http://www.w3.org/2000/svg'
                width='15'
                height='15'
                viewBox='0 0 48 48'
              >

                <path
                  fill='#ffffff'
                  fill-rule='evenodd'
                  stroke='#ffffff'
                  stroke-linecap='round'
                  stroke-linejoin='round'
                  stroke-width='4'
                  d='m4 24l5-5l10 10L39 9l5 5l-25 25z'
                  clip-rule='evenodd'
                />

              </svg>
            </p>
          </button>
        </div>
      </div>
    </div>
    <div class='flex items-center justify-between w-full p-3 bg-white rounded-md'>
      <div>
        <h1 class='text-lg'>Choose maximum pen line thickness</h1>
      </div>
      <div class='flex items-center space-x-4'>
        <input v-model='options.maxWidth' type='range' :min='0' :max='10' />
        <p>{{ options.maxWidth }}</p>
      </div>
    </div>
    <div class='flex items-center justify-between w-full p-3 bg-white rounded-md'>
      <div>
        <h1 class='text-lg'>Choose minimum pen line thickness</h1>
      </div>
      <div class='flex items-center space-x-4'>
        <input v-model='options.minWidth' type='range' :min='0' :max='10' />
        <p>{{ options.minWidth }}</p>
      </div>
    </div>
  </div>
</template>

更多内容可以点击这里[2],也可以在这里在线体验。

简要步骤和代码


具体代码

新建一个HandPaintedDialog.vue

具体代码如下:

<script setup lang="ts">
import { useDisplayStore } from '@/stores'
import { toTypedSchema } from '@vee-validate/yup'
import { FieldForm } from 'vee-validate'
import * as yup from 'yup'

import { useStore } from '@/stores'
const displayStore = useDisplayStore()
const { toggleShowHandPaintedDialog } = displayStore

import { cn } from '@/lib/utils'
import { VueSignaturePad } from '@selemondev/vue3-signature-pad'
import type { CanvasSignature } from '@selemondev/vue3-signature-pad'
import { Label, type LabelProps } from 'radix-vue'
import { computed, type HTMLAttributes } from 'vue'

import PickColors from 'vue-pick-colors'

const props = defineProps<LabelProps & { class?: HTMLAttributes[`class`] }>()
const store = useStore()

const delegatedProps = computed(() => {
  const { class: _, ...delegated } = props

  return delegated
})

const signaturePadOptions = ref({
  maxWidth2,
  minWidth2,
})

const colors = [
  {
    color'rgb(15, 76, 129)',
  },
  {
    color'rgb(250, 81, 81)',
  },
  {
    color'rgb(0, 152, 116)',
  },
  {
    color'rgb(51, 133, 255)',
  },
  {
    color'rgb(85, 201, 234)',
  },
  {
    color'rgb(85, 255, 51)',
  },
]

const signature = ref<Signature>()

function handleUndo() {
  return signature.value?.undo()
}

function handleClearCanvas() {
  return signature.value?.clearCanvas()
}

function handleSaveSignature(format: string) {
  copyBase64ImageToClipboard(signature.value?.saveSignature(format));
(${signature.value?.saveSignature(format)})\n`, `end`)
  toggleShowHandPaintedDialog()
  toast.success(`
已复制到粘贴板!`)
}

async function copyBase64ImageToClipboard(base64Image: string) {
    const img = new Image()
    img.src = base64Image
    img.onload = async () => {
        const canvas = document.createElement("canvas")
        const ctx = canvas.getContext("2d")
        if (ctx) {
            canvas.width = img.width
            canvas.height = img.height
            ctx.drawImage(img, 0, 0)
            canvas.toBlob(async (blob) => {
                if (blob) {
                    const clipboardItem = new ClipboardItem({ "image/png": blob })
                    await navigator.clipboard.write([clipboardItem])
                    console.log("PNG image copied to clipboard successfully!")
                    toRaw(store.editor!).replaceSelection(`
\n${await readClipboardAsMarkdown()}`, `end`)
                } else {
                    console.error("Failed to convert canvas to blob.")
                }
            }, "image/png")
        }
    };

    img.onerror = (error) => {
        console.error("Failed to load image:", error)
    };
}

async function readClipboardAsMarkdown() {
    try {
        const clipboardItems = await navigator.clipboard.read()
        let markdown = '';

        for (const clipboardItem of clipboardItems) {
            const types = clipboardItem.types

            for (const type of types) {
                if (type.startsWith("text/")) {
                    const blob = await clipboardItem.getType(type)
                    const text = await blob.text()
                    markdown += text + '\n'
                } else if (type.startsWith("image/")) {
                    const blob = await clipboardItem.getType(type)
                    // const imageUrl = URL.createObjectURL(blob)
                    const imageUrl = await uploadImage(blob)
                    markdown += `
![Image](${imageUrl})\n`
                }
            }
        }

        console.log("Markdown Output:")
        console.log(markdown)
        return markdown

    } catch (error) {
        console.error("Failed to read clipboard contents:", error)
    }
}

async function uploadImage(blob: Blob): Promise<string> {
    const file = new File([blob], 'image.png', { type: 'image/png' })
    const formData = new FormData()
    formData.append('file', file)
    const response = await fetch(vueSignaturePadConfig.value.imgHost, {
        method: 'POST',
        body: formData,
    })

    if (!response.ok) {
        const imageUrl = URL.createObjectURL(blob)
        const customFileName = 'image_' + Date.now() + '.png'
        return imageUrl+'/'+customFileName
    }

    const result = await response.json()
    return result.url
}

const options = [
  {
    value: `
default`,
    label: `
默认`,
  },
  {
    value: `
vueSignaturePad`,
    label: `
配置`,
  },
]
const handPaintedHost = ref(`
default`)
const activeName = ref(`
handPainted`)
const vueSignaturePadSchema = toTypedSchema(yup.object({
  width: yup.string().optional(),
  height: yup.string().optional(),
  maxWidth: yup.string().optional(),
  minWidth: yup.string().optional(),
  penColor:yup.string().optional(),
  backgroundColor: yup.string().optional(),
  imgHost: yup.string().required(`
域名不能为空`),
}))

const vueSignaturePadConfig = ref(localStorage.getItem(`
vueSignaturePadConfig`)
  ? JSON.parse(localStorage.getItem(`
vueSignaturePadConfig`)!)
  : { imgHost: `
http://localhost:3000/upload`, width: `950px`, height: `400px`, maxWidth: `10`, minWidth: `10`, penColor: 'rgb(0,0,0)', backgroundColor: `hsl(var(--muted))` })

function vueSignaturePadSubmit(formValues: any) {
  localStorage.setItem(`vueSignaturePadConfig`JSON.stringify(formValues))
  vueSignaturePadConfig.value = formValues
  toast.success(`保存成功`)
}

</script>

<template>
  <Dialog v-model:open="displayStore.isShowHandPaintedDialog">
    <DialogContent class="max-w-max" @pointer-down-outside="ev => ev.preventDefault()">
      <Tabs v-model="activeName" class="w-max">
        <TabsList>
          <TabsTrigger value="handPainted">
            手绘
          </TabsTrigger>
          <TabsTrigger v-for="item in options.filter(item => item.value !== 'default')" :key="item.value" :value="item.value">
            {{ item.label }}
          </TabsTrigger>
        </TabsList>

        <TabsContent value="handPainted">
          <div class='flex flex-col space-y-2'>
            <div class='p-4 rounded-md'>
              <div class='relative bg-muted text-muted-foreground rounded-md' :style="{background: vueSignaturePadConfig.backgroundColor}">
                <VueSignaturePad
                  ref='signature'
                  :height='vueSignaturePadConfig.height'
                  :width='vueSignaturePadConfig.width'
                  :max-width='signaturePadOptions.maxWidth'
                  :min-width='signaturePadOptions.minWidth'
                  :options='{
                    penColor: vueSignaturePadConfig.penColor,
                    backgroundColor: vueSignaturePadConfig.backgroundColor,
                  }'

                />


                <div class='absolute flex flex-col space-y-2 top-3 right-4'>
                  <button
                    type='button'
                    class='grid p-2 bg-white rounded-md shadow-md place-items-center'
                    @click='handleUndo'
                  >

                    <svg
                      xmlns='http://www.w3.org/2000/svg'
                      width='20'
                      height='20'
                      viewBox='0 0 24 24'
                    >

                      <path
                        fill='none'
                        stroke='#000'
                        stroke-linecap='round'
                        stroke-linejoin='round'
                        stroke-width='2'
                        d='M10 8H5V3m.291 13.357a8 8 0 1 0 .188-8.991'
                      />

                    </svg>
                  </button>
                  <button
                    type='button'
                    class='grid p-2 bg-white rounded-md shadow-md place-items-center'
                    @click='handleClearCanvas'
                  >

                    <svg
                      xmlns='http://www.w3.org/2000/svg'
                      width='20'
                      height='20'
                      viewBox='0 0 14 14'
                    >

                      <path
                        fill='none'
                        stroke='#000'
                        stroke-linecap='round'
                        stroke-linejoin='round'
                        d='M11.5 8.5h-9l-.76 3.8a1 1 0 0 0 .21.83a1 1 0 0 0 .77.37h8.56a1.002 1.002 0 0 0 .77-.37a1.001 1.001 0 0 0 .21-.83zm0-3a1 1 0 0 1 1 1v2h-11v-2a1 1 0 0 1 1-1H4a1 1 0 0 0 1-1v-2a2 2 0 1 1 4 0v2a1 1 0 0 0 1 1zm-3 8V11'
                      />

                    </svg>
                  </button>
                  <button
                    type='button'
                    class='grid p-2 bg-white rounded-md shadow-md place-items-center'
                    @click='handleSaveSignature("image/svg")'
                  >

                    <svg
                      xmlns='http://www.w3.org/2000/svg'
                      width='20'
                      height='20'
                      viewBox='0 0 24 24'
                    >

                      <path
                        fill='#000'
                        d='M21 7v14H3V3h14zm-2 .85L16.15 5H5v14h14zM12 18q1.25 0 2.125-.875T15 15t-.875-2.125T12 12t-2.125.875T9 15t.875 2.125T12 18m-6-8h9V6H6zM5 7.85V19V5z'
                      />

                    </svg>
                  </button>
                </div>
              </div>
            </div>
            <div :class="cn(
              'flex items-center justify-center justify-between w-full p-3 bg-muted text-muted-foreground',
              props.class,
            )"
>

              <div>
                <h1 class='text-lg'>画笔颜色</h1>
              </div>
              <div class='flex items-center space-x-4'>
              <div>
                <PickColors size=30 id='pick-color' class='grid w-8 h-8 rounded-full place-items-center' placement='bottom' :theme="store.isDark ? 'dark' : 'light'" show-alpha='true' v-model:value="vueSignaturePadConfig.penColor"/>
              </div>
                <div v-for='color in colors' :key='color.color'>
                  <button
                    type='button'
                    :style='{ background: color.color }'
                    class='grid w-8 h-8 rounded-full place-items-center'
                    @click='vueSignaturePadConfig.penColor = color.color'
                  >

                    <p v-if='vueSignaturePadConfig.penColor === color.color'>
                      <svg
                        xmlns='http://www.w3.org/2000/svg'
                        width='15'
                        height='15'
                        viewBox='0 0 48 48'
                      >

                        <path
                          fill='#ffffff'
                          fill-rule='evenodd'
                          stroke='#ffffff'
                          stroke-linecap='round'
                          stroke-linejoin='round'
                          stroke-width='4'
                          d='m4 24l5-5l10 10L39 9l5 5l-25 25z'
                          clip-rule='evenodd'
                        />

                      </svg>
                    </p>
                  </button>
                </div>
              </div>
            </div>
            <div :class="cn(
              'flex items-center justify-center justify-between w-full p-3 bg-muted text-muted-foreground',
              props.class,
            )"
>

              <div>
                <h1 class='text-lg'>选择最大线条粗细</h1>
              </div>
              <div class='flex items-center space-x-4'>
                <input v-model='signaturePadOptions.maxWidth' type='range' :min='0' :max='vueSignaturePadConfig.maxWidth' />
                <p>{{ signaturePadOptions.maxWidth }}</p>
              </div>
            </div>
            <div :class="cn(
              'flex items-center justify-center justify-between w-full p-3 bg-muted text-muted-foreground',
              props.class,
            )"
>

              <div>
                <h1 class='text-lg'>选择最小笔线粗细</h1>
              </div>
              <div class='flex items-center space-x-4'>
                <input v-model='signaturePadOptions.minWidth' type='range' :min='0' :max='vueSignaturePadConfig.minWidth' />
                <p>{{ signaturePadOptions.minWidth }}</p>
              </div>
            </div>
          </div>
        </TabsContent>

        <TabsContent value="vueSignaturePad">
          <Form :validation-schema="vueSignaturePadSchema" :initial-values="vueSignaturePadConfig" @submit="vueSignaturePadSubmit">
            <Field v-slot="{ field, errorMessage }" name="imgHost">
              <FormItem label="图片上传域名" required :error="errorMessage">
                <Input
                  v-bind="field"
                  v-model="field.value"
                  placeholder="如:http://localhost:5173"
                />

              </FormItem>
            </Field>

            <Field v-slot="{ field, errorMessage }" name="width">
              <FormItem label="画板宽度" :error="errorMessage">
                <Input
                  v-bind="field"
                  v-model="field.value"
                  placeholder="如:950px"
                />

              </FormItem>
            </Field>

            <Field v-slot="{ field, errorMessage }" name="height">
              <FormItem label="画板高度" :error="errorMessage">
                <Input
                  v-bind="field"
                  v-model="field.value"
                  placeholder="如:400px"
                />

              </FormItem>
            </Field>

            <Field v-slot="{ field, errorMessage }" name="maxWidth">
              <FormItem label="选择最大线条粗细" :error="errorMessage">
                <Input
                  v-bind="field"
                  v-model="field.value"
                  placeholder="如:10"
                />

              </FormItem>
            </Field>

            <Field v-slot="{ field, errorMessage }" name="minWidth">
              <FormItem label="选择最小笔线粗细" :error="errorMessage">
                <Input
                  v-bind="field"
                  v-model="field.value"
                  placeholder="如:10"
                />

              </FormItem>
            </Field>

            <Field v-slot="{ field, errorMessage }" name="penColor">
              <FormItem label="画笔颜色" :error="errorMessage">
                <Input
                  v-bind="field"
                  v-model="field.value"
                  placeholder="如:rgb(0,0,0)"
                />

              </FormItem>
            </Field>

            <Field v-slot="{ field, errorMessage }" name="backgroundColor">
              <FormItem label="画布背景颜色" :error="errorMessage">
                <Input
                  v-bind="field"
                  v-model="field.value"
                  placeholder="如:hsl(var(--muted))或者rgb(255, 255, 255)"
                />

              </FormItem>
            </Field>

            <FormItem>
              <label>
                画布背景颜色在生成图片时不会显示
              </label>
            </FormItem>

            <FormItem>
              <Button type="submit">
                保存配置
              </Button>
            </FormItem>
          </Form>
        </TabsContent>

        <TabsContent value="formCustom">
          <CustomUploadForm />
        </TabsContent>
      </Tabs>
    </DialogContent>
  </Dialog>
</template>

<style lang="less" scoped>
#pick-color > :first-child {
  border-radius15px !important;
  vertical-align: bottom;
  border0px !important;
}
</style>

然后把HandPaintedDialog.vue组件添加到Codem)irrorEditor

...
<handPaintedDialog />
...

更多手绘效果


本人绘画技术有很大的进步空间~~~~~~~~~~~~~~~~





相关连接


1. WeChat Markdown Editor github[3]
2. WeChat Markdown Editor gitee[4]
3. Vue 3 Signature Pad[5]
4. Vue 3 Signature Pad github[6]
5. vue-pick-colors组件 github[7]




引用链接


[1] Icones: https://icones.js.org
[2] 点击这里: https://vue3-signature-pad.vercel.app
[3] WeChat Markdown Editor github: https://github.com/doocs/md
[4] WeChat Markdown Editor gitee: https://gitee.com/doocs/md
[5] Vue 3 Signature Pad: https://vue3-signature-pad.vercel.app
[6] Vue 3 Signature Pad github: https://github.com/selemondev/vue3-signature-pad
[7] vue-pick-colors组件 github: https://github.com/qiuzongyuan/vue-pick-colors

 


发表评论