Merge pull request #80 from JeremyYu-cn/feat-upgrade-vue3

Feat: merge branch and convert wText component to composition API
This commit is contained in:
Jeremy Yu 2024-03-10 18:46:18 +00:00 committed by GitHub
commit d37c41dff5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
98 changed files with 7115 additions and 25368 deletions

View File

@ -13,7 +13,6 @@ module.exports = {
// 自定义你的规则
'vue/component-tags-order': ['off'],
'vue/no-multiple-template-root': ['off'],
'max-params': ['off'],
// 'no-undef': 'off', // 禁止使用未定义的变量会把TS声明视为变量暂时关闭
},
parserOptions: {

1
.gitignore vendored
View File

@ -15,7 +15,6 @@ screenshot/_apidoc/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
yarn.lock*
pnpm-debug.log*
# Editor directories and files

View File

@ -2,14 +2,14 @@
---
## 迅排设计
## Poster Design
一款漂亮且功能强大的在线海报图片设计器,仿稿定设计。
适用于海报图片生成、电商分享图、文章长图、视频/公众号封面等多种场景。
迅排设计是一款漂亮易用且功能强大的开源创意图片编辑器是对标稿定设计、创客贴、Canva 等商业产品的免费在线设计工具。
[![](https://xp.palxp.cn/images/2023-7-16-1689500112694.gif)](https://design.palxp.cn/)
适用于多种场景:海报图片生成、电商分享图、文章长图、视频/公众号封面等,无需下载软件即可轻松实现云端编辑、迅速完成图文排版。
- 丝滑的页面操作体验,丰富的交互细节,基础功能完善
- 采用服务端生成图片,能确保多端出图统一性,支持各种 CSS 特性
- 简易 AI 抠图工具,上传图片一键去除背景
@ -40,7 +40,7 @@ npm run serve
![](https://xp.palxp.cn/images/2023-7-16-1689498291322.png)
访问 http://127.0.0.1:3000/ 查看网页。点此查看[完整说明文档](https://xp.palxp.cn/#/articles/1689319644311)。
访问 http://127.0.0.1:5173/ 查看网页。点此查看[完整说明文档](https://xp.palxp.cn/#/articles/1689319644311)。
### 图片生成服务
@ -56,7 +56,7 @@ npm run serve
本项目最早使用 Vue2 开发,现改用 Vue3 重构中。[一些迭代计划记录](https://xp.palxp.cn/#/articles/1689319986889?id=%e8%bf%ad%e4%bb%a3%e8%ae%a1%e5%88%92).
目前开源版仍在持续迭代中,还有很多的不足,我的目标是做一款能对标稿定设计、创客贴、Canva等商业产品的强大在线设计器
目前开源版仍在持续迭代中,还有很多的不足,可以将你遇到的问题在 Issues 中提出,或者提交 Pull Request 帮助完善
### 感谢
@ -91,7 +91,9 @@ npm run serve
### 友情赞助商
[![](https://xp.palxp.cn/images/2024-3-3-1709450701432.png)](https://dooring.vip/)
| Dooring低代码 | DrawOn桌案 |
| --- | --- |
| <a href="https://dooring.vip/"> <img style="height: 90px" src="https://github.com/palxiao/poster-design/assets/21021314/2240801f-8484-4fd2-8505-8205daa6d53c" /></a> | <a href="https://www.drawon.cn?useSource=hb1"> <img style="height: 120px" src="https://github.com/palxiao/poster-design/assets/21021314/258bb6ec-4e1e-4c86-b45c-22946213f209" /></a> |
### `Contributions`
@ -101,5 +103,5 @@ npm run serve
### `LICENSE`
本项目完全免费,遵循 [MIT 开源许可证](https://github.com/palxiao/poster-design/blob/main/LICENSE)
本项目完全免费,可在保留 [MIT 开源许可证](https://github.com/palxiao/poster-design/blob/main/LICENSE) 的前提下使用。

5539
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -14,10 +14,9 @@
"publish-fast": "git add . && git commit -m 'build: auto publish' && sh script/publish.sh"
},
"dependencies": {
"@palxp/color-picker": "^1.5.5",
"@palxp/image-extraction": "^1.2.4",
"@palxp/color-picker": "workspace:*",
"@palxp/image-extraction": "workspace:*",
"@scena/guides": "^0.18.1",
"@types/cropperjs": "^1.3.0",
"@webtoon/psd": "^0.4.0",
"axios": "^0.21.1",
"core-js": "^3.6.5",
@ -57,9 +56,12 @@
"vite": "^5.1.4",
"vue-tsc": "^1.8.27"
},
"workspaces": [
"packages/*"
],
"browserslist": [
"> 1%",
"last 2 versions",
"not dead"
]
"Chrome >= 90"
],
"website": "https://design.palxp.cn",
"homepage": "https://xp.palxp.cn"
}

View File

@ -0,0 +1,168 @@
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.5.5](https://github.com/palxiao/front-end-arsenal/compare/@palxp/color-picker@1.5.4...@palxp/color-picker@1.5.5) (2024-01-31)
**Note:** Version bump only for package @palxp/color-picker
## [1.5.4](https://github.com/palxiao/front-end-arsenal/compare/@palxp/color-picker@1.5.3...@palxp/color-picker@1.5.4) (2024-01-31)
**Note:** Version bump only for package @palxp/color-picker
## [1.5.3](https://github.com/palxiao/front-end-arsenal/compare/@palxp/color-picker@1.5.2...@palxp/color-picker@1.5.3) (2024-01-31)
### Bug Fixes
* **custom:** remove some configurations ([30f3257](https://github.com/palxiao/front-end-arsenal/commit/30f3257168bd22a15feb04e58cc0fb8fcac6807e))
## [1.5.2](https://github.com/palxiao/front-end-arsenal/compare/@palxp/color-picker@1.5.1...@palxp/color-picker@1.5.2) (2023-11-30)
**Note:** Version bump only for package @palxp/color-picker
## [1.5.1](https://github.com/palxiao/front-end-arsenal/compare/@palxp/color-picker@1.5.0...@palxp/color-picker@1.5.1) (2023-11-30)
### Bug Fixes
* **component:** colorPicker gradient delete ([34b5d8b](https://github.com/palxiao/front-end-arsenal/commit/34b5d8b933a8e927802ba546528fdd1072d7de9b))
# [1.5.0](https://github.com/palxiao/front-end-arsenal/compare/@palxp/color-picker@1.4.0...@palxp/color-picker@1.5.0) (2023-11-29)
### Features
* **component:** add a angle handle ([635a62c](https://github.com/palxiao/front-end-arsenal/commit/635a62c379ae05d079cda7a9da0cf74ec0a81822))
# [1.4.0](https://github.com/palxiao/front-end-arsenal/compare/@palxp/color-picker@1.3.3...@palxp/color-picker@1.4.0) (2023-11-28)
### Features
* **component:** add gradient colorPicker ([e0cbdd2](https://github.com/palxiao/front-end-arsenal/commit/e0cbdd20d9dad2ebc0de64e66958058bc4bf0cd6))
## [1.3.3](https://github.com/palxiao/front-end-arsenal/compare/@palxp/color-picker@1.3.2...@palxp/color-picker@1.3.3) (2023-10-10)
**Note:** Version bump only for package @palxp/color-picker
## [1.3.2](https://github.com/palxiao/front-end-arsenal/compare/@palxp/color-picker@1.3.1...@palxp/color-picker@1.3.2) (2023-10-08)
**Note:** Version bump only for package @palxp/color-picker
## [1.3.1](https://github.com/palxiao/front-end-arsenal/compare/@palxp/color-picker@1.3.0...@palxp/color-picker@1.3.1) (2023-08-22)
**Note:** Version bump only for package @palxp/color-picker
# [1.3.0](https://github.com/palxiao/front-end-arsenal/compare/@palxp/color-picker@1.2.5...@palxp/color-picker@1.3.0) (2023-08-22)
### Features
* **component:** color-picker add blur ([1a3d1b0](https://github.com/palxiao/front-end-arsenal/commit/1a3d1b073dcbbc1a8c30ad625cd7eed285665932))
## [1.2.5](https://github.com/palxiao/front-end-arsenal/compare/@palxp/color-picker@1.2.4...@palxp/color-picker@1.2.5) (2023-06-29)
**Note:** Version bump only for package @palxp/color-picker
## [1.2.4](https://github.com/palxiao/front-end-arsenal/compare/@palxp/color-picker@1.2.3...@palxp/color-picker@1.2.4) (2023-06-29)
### Bug Fixes
* 修复一个报错 ([41fdfc8](https://github.com/palxiao/front-end-arsenal/commit/41fdfc8a1a6ef32e221c7fae97b1aa5e24b63a0e))
## [1.2.3](https://github.com/palxiao/front-end-arsenal/compare/@palxp/color-picker@1.2.2...@palxp/color-picker@1.2.3) (2023-06-21)
**Note:** Version bump only for package @palxp/color-picker
## [1.2.2](https://github.com/palxiao/front-end-arsenal/compare/@palxp/color-picker@1.2.1...@palxp/color-picker@1.2.2) (2023-05-29)
**Note:** Version bump only for package @palxp/color-picker
## [1.2.1](https://github.com/palxiao/front-end-arsenal/compare/@palxp/color-picker@1.2.0...@palxp/color-picker@1.2.1) (2023-05-29)
**Note:** Version bump only for package @palxp/color-picker
# 1.2.0 (2023-05-29)
### Features
* **component:** 新增 ColorPicker 组件 ([effebc9](https://github.com/palxiao/front-end-arsenal/commit/effebc9795ce1426f3126c1fe07e58673da5748a))
# 1.1.0 (2023-05-29)
### Features
* **component:** 新增 ColorPicker 组件 ([effebc9](https://github.com/palxiao/front-end-arsenal/commit/effebc9795ce1426f3126c1fe07e58673da5748a))

View File

@ -0,0 +1,32 @@
<!--
* @Author: ShawnPhang
* @Date: 2023-05-29 22:54:18
* @Description:
* @LastEditors: ShawnPhang <https://m.palxp.cn>
* @LastEditTime: 2024-01-31 10:51:35
-->
<img style="display: inline-block;" src="https://img.shields.io/github/watchers/palxiao/front-end-arsenal?style=social" />
<img style="display: inline-block;" src="https://img.shields.io/github/forks/palxiao/front-end-arsenal?style=social" />
<img style="display: inline-block;" src="https://img.shields.io/github/stars/palxiao/front-end-arsenal?style=social" />
# color-picker
> TODO: 颜色取色器,适用于 Vue3
<img style="display: inline-block;" src="https://img.shields.io/npm/v/@palxp/color-picker" />
<img style="display: inline-block;" src="https://img.shields.io/bundlephobia/min/@palxp/color-picker?color=%2344cc88" />
<img style="display: inline-block;" src="https://img.shields.io/npm/dm/@palxp/color-picker" />
## Usage
```
yarn add @palxp/color-picker
import colorPicker from '@palxp/color-picker'
```
## API
[API Docs 链接](/#/docs)
<iframe src="/#/docs/color-picker/index?preview=true" frameborder="0"></iframe>

View File

@ -0,0 +1,150 @@
<!--
* @Author: ShawnPhang
* @Date: 2023-11-29 10:34:54
* @Description: 角度手柄
* @LastEditors: ShawnPhang <https://m.palxp.cn>
* @LastEditTime: 2023-11-29 19:24:14
-->
<template>
<div class="angle-input-box">
<input ref="numInput" v-model="num" class="angle-input" @focus="visiable = true" @blur="visiable = false" @input="inputChange" />
<div v-show="visiable" class="AngleHandle" @mousedown="touch($event, true)" @mouseup="touch($event, false)">
<div class="angle" @mouseup="turn" @mousemove="turn">
<div :style="`transform: rotate(${angleInDegrees}deg)`" class="line"></div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, ref, watch, computed } from 'vue'
export default defineComponent({
props: ['modelValue'],
emits: ['change', 'update:modelValue'],
setup(props, { emit }) {
const num = ref(90)
const numInput = ref(null)
const angleInDegrees = computed(() => {
return num.value - 90
})
let inProcess = false
const visiable = ref(false)
const inputChange = (e: any) => {
emit('change', e)
}
watch(
() => num.value,
(v) => {
props.modelValue !== num.value && emit('update:modelValue', v)
emit('change')
},
)
watch(
() => props.modelValue,
(v) => {
num.value = v
},
)
const turn = (e: any) => {
if (!inProcess) {
return
}
const origin = { x: 27, y: 27 }
//
const deltaX = e.offsetX - origin.x
const deltaY = e.offsetY - origin.y
//
const angleInRadians = Math.atan2(deltaY, deltaX)
//
const angleInDegrees = (angleInRadians * 180) / Math.PI
num.value = Math.round(angleInDegrees + 90)
}
const touch = (e: any, isHandle: boolean) => {
e.preventDefault()
inProcess = isHandle
}
return { inputChange, num, turn, touch, angleInDegrees, numInput, visiable }
},
})
</script>
<style lang="less">
.angle-input {
width: 38px;
margin-left: 5px;
padding: 0 0 0 4px;
border: 1px solid #e8eaec;
border-radius: 4px;
position: relative;
}
.angle-input-box {
position: relative;
}
.angle-input-box::after {
content: '°';
width: 5px;
height: 2px;
position: absolute;
right: 2px;
top: 0;
}
.AngleHandle {
position: absolute;
z-index: 2;
right: 2px;
margin-top: 3px;
background: #ffffff;
width: 60px;
height: 60px;
border-radius: 7px;
box-shadow: 0 0 2px rgb(0 0 0 / 60%);
display: flex;
align-items: center;
justify-content: center;
.angle {
width: 54px;
height: 54px;
position: relative;
overflow: hidden;
background: #f1f2f4;
border-radius: 50%;
user-select: none;
cursor: pointer;
}
.line {
position: absolute;
top: 50%;
left: 50%;
width: 50%;
height: 1px;
background: #999999;
pointer-events: none;
transform-origin: left top;
}
.line::before {
position: absolute;
content: '';
left: -1px;
top: -1px;
width: 3px;
height: 3px;
border-radius: 50%;
background: #999999;
}
.line::after {
position: absolute;
content: '';
right: 0;
top: -2px;
width: 5px;
height: 5px;
border-radius: 50%;
background: #999999;
}
}
</style>

View File

@ -0,0 +1,39 @@
<template>
<div class="tab-panel" :style="rootStyle">
<slot />
</div>
</template>
<script>
export default {
name: 'TabPanel',
}
</script>
<script setup>
import { computed, getCurrentInstance, ref } from 'vue'
const vm = getCurrentInstance()
vm.parent.exposed.tabs.value.push(vm)
defineProps({
// Tabs label
label: {
type: String,
required: true,
},
})
const active = ref(false)
const rootStyle = computed(() => ({
display: active.value ? 'block' : 'none',
}))
function changeActive(value) {
active.value = value
}
defineExpose({
changeActive,
})
</script>

View File

@ -0,0 +1,121 @@
<template>
<div class="my-tabs">
<div class="my-tabs__header p-0.5 mb-3 rounded bg-gray-100 cursor-pointers">
<div class="my-tabs__header-shell relative flex justify-between">
<div v-for="(tab, index) in tabs" :key="tab.props.label" class="my-tab__title relative flex-auto py-1 text-center" :class="{ 'my-active': tab.props.label === value }" @click="onClickTab(tab, index)">
{{ tab.props.label }}
</div>
<div class="my-tab__slider" :style="sliderStyle"></div>
</div>
</div>
<div class="my-tabs__content">
<slot />
</div>
</div>
</template>
<script>
export default {
name: 'Tabs',
}
</script>
<script setup>
import { ref, reactive, onMounted, watch, nextTick } from 'vue'
const props = defineProps({
value: {
type: String,
required: true,
},
})
const emit = defineEmits(['update:value', 'change'])
watch(
() => props.value,
() => {
changeTab()
},
)
const tabs = ref([])
const sliderStyle = reactive({ width: 0, left: 0 })
let tabWidth = 0
onMounted(() => {
//
tabWidth = 100 / tabs.value.length
sliderStyle.width = `${tabWidth}%`
changeTab()
})
let preActiveTabVM = null
async function changeTab(index = -1) {
if (index < 0) {
index = tabs.value.findIndex((vm) => vm.props.label === props.value)
}
sliderStyle.left = `${tabWidth * index}%`
// tab
try {
await nextTick()
preActiveTabVM?.exposed?.changeActive?.(false)
preActiveTabVM = tabs.value[index]
preActiveTabVM.exposed?.changeActive?.(true)
} catch (error) {}
}
function onClickTab(tab, index) {
emit('update:value', tab.props.label)
changeTab(index)
}
defineExpose({ tabs })
</script>
<style scoped>
.my-tabs__header {
margin-bottom: 0.75rem;
border-radius: 0.25rem;
--tw-bg-opacity: 1;
background-color: rgb(243 244 246 / var(--tw-bg-opacity));
padding: 0px;
padding: 0.125rem;
}
.my-tabs__header-shell {
justify-content: space-between;
position: relative;
display: flex;
}
.my-tab__title {
text-align: center;
position: relative;
flex: 1 1 auto;
padding-top: 0.25rem;
padding-bottom: 0.25rem;
z-index: 1;
}
.my-tab__title.my-active {
font-weight: bold;
}
.my-tab__slider {
position: absolute;
bottom: 0px;
top: 0px;
border-radius: 0.25rem;
--tw-bg-opacity: 1;
background-color: rgb(255 255 255 / var(--tw-bg-opacity));
--tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
transition-property: all;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
transition-duration: 150ms;
}
</style>

View File

@ -0,0 +1,21 @@
<!--
* @Author: ShawnPhang
* @Date: 2023-05-29 15:41:22
* @Description: 吸管 SVG
* @LastEditors: ShawnPhang <site: m.palxp.cn>
* @LastEditTime: 2023-05-29 15:48:17
-->
<template>
<svg t="1685345224620" class="sd-xggj" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8489" width="20" height="20">
<path d="M716.288 140.501333a42.666667 42.666667 0 0 1 60.373333 0l90.496 90.496a42.666667 42.666667 0 0 1 0 60.330667l-120.661333 120.704L595.626667 261.12l120.661333-120.661333zM520.192 185.770667l301.696 301.653333-60.330667 60.373333-301.653333-301.696 60.288-60.330666z" p-id="8490"></path>
<path d="M580.565333 366.762667l-60.373333-60.330667-362.026667 362.026667V853.333333l181.034667-3.84 362.026667-362.026666-60.330667-60.373334-331.861333 331.904-60.373334-60.373333 331.904-331.861333z" p-id="8491"></path>
</svg>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
setup() {},
})
</script>

View File

@ -0,0 +1,107 @@
/* ! tailwindcss v3.3.2 | MIT License | https://tailwindcss.com */
/*
Some configurations are extracted, and tailwindcss are not used in this project
*/
*,
::before,
::after {
--tw-border-spacing-x: 0;
--tw-border-spacing-y: 0;
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-rotate: 0;
--tw-skew-x: 0;
--tw-skew-y: 0;
--tw-scale-x: 1;
--tw-scale-y: 1;
--tw-pan-x: ;
--tw-pan-y: ;
--tw-pinch-zoom: ;
--tw-scroll-snap-strictness: proximity;
--tw-gradient-from-position: ;
--tw-gradient-via-position: ;
--tw-gradient-to-position: ;
--tw-ordinal: ;
--tw-slashed-zero: ;
--tw-numeric-figure: ;
--tw-numeric-spacing: ;
--tw-numeric-fraction: ;
--tw-ring-inset: ;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-color: rgb(59 130 246 / 0.5);
--tw-ring-offset-shadow: 0 0 #0000;
--tw-ring-shadow: 0 0 #0000;
--tw-shadow: 0 0 #0000;
--tw-shadow-colored: 0 0 #0000;
--tw-blur: ;
--tw-brightness: ;
--tw-contrast: ;
--tw-grayscale: ;
--tw-hue-rotate: ;
--tw-invert: ;
--tw-saturate: ;
--tw-sepia: ;
--tw-drop-shadow: ;
--tw-backdrop-blur: ;
--tw-backdrop-brightness: ;
--tw-backdrop-contrast: ;
--tw-backdrop-grayscale: ;
--tw-backdrop-hue-rotate: ;
--tw-backdrop-invert: ;
--tw-backdrop-opacity: ;
--tw-backdrop-saturate: ;
--tw-backdrop-sepia: ;
}
::backdrop {
--tw-border-spacing-x: 0;
--tw-border-spacing-y: 0;
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-rotate: 0;
--tw-skew-x: 0;
--tw-skew-y: 0;
--tw-scale-x: 1;
--tw-scale-y: 1;
--tw-pan-x: ;
--tw-pan-y: ;
--tw-pinch-zoom: ;
--tw-scroll-snap-strictness: proximity;
--tw-gradient-from-position: ;
--tw-gradient-via-position: ;
--tw-gradient-to-position: ;
--tw-ordinal: ;
--tw-slashed-zero: ;
--tw-numeric-figure: ;
--tw-numeric-spacing: ;
--tw-numeric-fraction: ;
--tw-ring-inset: ;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-color: rgb(59 130 246 / 0.5);
--tw-ring-offset-shadow: 0 0 #0000;
--tw-ring-shadow: 0 0 #0000;
--tw-shadow: 0 0 #0000;
--tw-shadow-colored: 0 0 #0000;
--tw-blur: ;
--tw-brightness: ;
--tw-contrast: ;
--tw-grayscale: ;
--tw-hue-rotate: ;
--tw-invert: ;
--tw-saturate: ;
--tw-sepia: ;
--tw-drop-shadow: ;
--tw-backdrop-blur: ;
--tw-backdrop-brightness: ;
--tw-backdrop-contrast: ;
--tw-backdrop-grayscale: ;
--tw-backdrop-hue-rotate: ;
--tw-backdrop-invert: ;
--tw-backdrop-opacity: ;
--tw-backdrop-saturate: ;
--tw-backdrop-sepia: ;
}

View File

@ -0,0 +1,15 @@
/*
* @Author: ShawnPhang
* @Date: 2023-05-29 14:24:41
* @Description:
* @LastEditors: ShawnPhang <site: m.palxp.cn>
* @LastEditTime: 2023-05-29 14:25:05
*/
import { App } from 'vue'
import Comp from './index.vue'
Comp.install = (app: App): void => {
app.component(Comp.name, Comp)
}
export default Comp

View File

@ -0,0 +1,729 @@
<!--
* @Author: ShawnPhang
* @Date: 2023-05-26 17:42:26
* @Description: 调色板
* @LastEditors: ShawnPhang <https://m.palxp.cn>
* @LastEditTime: 2024-01-31 10:44:41
-->
<template>
<div class="color-picker">
<Tabs v-if="modes.length > 1" :value="mode" @update:value="onChangeMode">
<TabPanel v-for="label in modes" :key="label" :label="label"> </TabPanel>
</Tabs>
<div v-else class="title">{{ mode }}</div>
<template v-if="showGradient">
<div v-show="mode === '渐变'" class="cp__gradient flex-center">
<div class="cp__gradient-bar">
<div ref="elGradientTrack" class="cpgb__track" style="width: 100%" :style="{ background: value }">
<!-- tabindex="-1" 是元素可以触发 keydown 事件 -->
<div
v-for="(gradient, index) in gradients"
:key="index"
:class="[
'cpgb__pointer',
{
'cpgb__pointer--active': gradient === activeGradient,
},
]"
:data-sort="index"
:style="{
left: `${gradient.offset * 100}%`,
background: gradient.color,
}"
tabindex="-1"
@mousedown="onMousedownGradientPointer(gradient)"
@keydown.stop="onKeyupGradientPointer"
></div>
</div>
</div>
<AngleHandleVue v-model="angle" @change="angleChange" />
</div>
</template>
<div ref="elPalette" class="cp__palette" :style="{ background: paletteBackground }">
<div class="cpp__color-saturation"></div>
<div class="cpp__color-value"></div>
<div ref="elPalettePointer" class="cpp__pointer"></div>
</div>
<div ref="elSliderHux" class="cp__slider cp__slider-hux">
<div class="cps__track">
<div ref="elSliderHuxPointer" class="cpst__pointer"></div>
</div>
</div>
<div ref="elSliderAlpha" class="cp__slider cp__slider-alpha">
<div class="cpsa__background" :style="sliderAlphaBackgroundStyle"></div>
<div class="cps__track">
<div ref="elSliderAlphaPointer" class="cpst__pointer"></div>
</div>
</div>
<div class="cp__box">
<div class="item" @click="onClickStraw">
<xiguan v-if="hasEyeDrop" />
<input v-else class="native" type="color" @input="onClickStraw" />
</div>
<!-- <input :value="value" @input="$emit('update:value', $event.target.value)" class="input" /> -->
<input v-if="mode === '渐变'" class="input" :value="activeGradient.color" />
<input v-else :value="value" class="input" @blur="onInputBlur" />
<template v-if="mode === '纯色'">
<div v-for="pc in predefine" :key="pc" class="item item-color" :style="{ background: pc }" @click="onClickStraw({ target: { value: pc } })"></div>
</template>
<!-- <input :value="alpha" class="w-12" size="small" :min="0" :max="100" @input="onChangeAlpha" @change="onChangeAlpha" /> -->
</div>
</div>
</template>
<script>
import './index.css'
export default {
name: 'ColorPicker',
inheritAttrs: false,
}
</script>
<script setup>
import { ref, reactive, computed, watch, onMounted, nextTick, onBeforeUnmount } from 'vue'
import { registerMoveableElement } from './utils/moveable.ts'
import { hexA2HSLA, HSLA2HexA, hex2RGB, RGB2HSL, hexA2RGBA, RGBA2HexA } from './utils/color.ts'
import { toGradientString, parseBackgroundValue, toolTip } from './utils/helper.ts'
import Tabs from './comps/Tabs.vue'
import xiguan from './comps/svg.vue'
import TabPanel from './comps/TabPanel.vue'
import { debounce } from 'throttle-debounce'
import AngleHandleVue from './comps/AngleHandle.vue'
const props = defineProps({
value: {
type: String,
default: '#ffffffff',
},
modes: {
type: Array,
default: () => ['纯色', '渐变'], //
},
defaultColor: {
type: String,
default: '#ffffffff',
},
defaultGradient: {
type: String,
default: 'linear-gradient(90deg, #fffae0ff 0%, #ffd1f1ff 100%)',
},
defaultImage: {
type: String,
default: 'https://st0.dancf.com/csc/157/material-2d-textures/0/20190714-174653-ed3c.jpg',
},
})
const emit = defineEmits(['update:value', 'change', 'native-pick', 'blur'])
const mode = ref(parseBackgroundValue(props.value)) //
const angle = ref(90)
const gradients = ref([])
const hsla = reactive({ h: 0, s: 0, l: 0, a: 0 })
const paletteBackground = ref('#f00')
const hex = ref('#000')
const alpha = ref(0)
let activeGradient = ref({})
const hasEyeDrop = 'EyeDropper' in window
const elGradientTrack = ref()
const elPalette = ref()
const elPalettePointer = ref()
const elSliderHuxPointer = ref()
const elSliderHux = ref()
const elSliderAlphaPointer = ref()
const elSliderAlpha = ref()
// const elStrawCanvas = ref();
let gradientMoveable = null
let paletteMoveable = null
let sliderHuxMoveable = null
let sliderAlphaMoveable = null
let mousedownGradientPointer = null
let backendHex = null
// palette sliderHux sliderAlpha pointer
let canChangeHSLAPointerPos = true
let canChangeHSLAPointerPosTimer = null
const predefine = ref([]) //
const record = {
color: props.defaultColor,
gradient: props.defaultGradient,
image: props.defaultImage,
}
const showGradient = computed(() => {
return props.modes.includes('渐变')
})
const sliderAlphaBackgroundStyle = computed(() => {
const rgb = hex2RGB(hex.value).join(',')
return {
background: `linear-gradient(to right, rgba(${rgb}, 0) 0%, rgb(${rgb}) 100%)`,
}
})
watch(activeGradient, (value) => {
setColor(value.color)
})
watch(hex, (value) => {
onChangeHex(value)
})
watch(
() => props.value,
(value) => {
const _mode = parseBackgroundValue(value)
if (_mode !== mode.value) {
mode.value = _mode
}
changeMode(_mode)
recordValue(value)
addHistory(value)
},
)
// TODO:
const addHistory = debounce(300, async (value) => {
const history = predefine.value
//
const index = history.indexOf(value)
if (index !== -1) {
predefine.value.splice(index, 1)
}
if (history.length >= 4) {
predefine.value.splice(history.length - 1, 1)
}
//
const head = [value]
predefine.value = head.concat(history)
})
const unwatchHSLA = watch(hsla, onChangeHSLA, { deep: true })
function onChangeHSLA(newHsla) {
const hexA = HSLA2HexA(...Object.values(newHsla))
let value
if (mode.value === '纯色') {
value = hexA
} else if (mode.value === '渐变') {
activeGradient.value.color = hexA
value = toGradientString(angle.value, gradients.value)
}
updateColorData(hexA)
updateValue(value)
}
onMounted(onMountedCallback)
async function onMountedCallback() {
elPalettePointer.value.style.left = `${hsla.s}%`
elPalettePointer.value.style.top = `${100 - hsla.l}%`
elSliderHuxPointer.value.style.left = `${(hsla.h / 360) * 100}%`
elSliderAlphaPointer.value.style.left = `${hsla.a * 100}%`
if (showGradient.value) {
gradientMoveable = registerMoveableElement(elGradientTrack.value, {
onmousedown: onMousedownGradient,
onmousemove: onMousemoveGradient,
onmouseup: onMouseupGradient,
})
}
function onMousedownGradient(position) {
if (mousedownGradientPointer) {
return
}
const index = gradients.value.findIndex((stop) => stop.offset >= position.x)
const start = gradients.value[index - 1]
const startRGBA = hexA2RGBA(start.color)
const end = gradients.value[index]
const endRGBA = hexA2RGBA(end.color)
const rgb = []
for (let i = 0; i < 3; i += 1) {
rgb.push(startRGBA[i] + (endRGBA[i] - startRGBA[i]) * position.x)
}
const a = end.offset - position.x - (position.x - start.offset) > 0 ? startRGBA[3] : endRGBA[3]
const color = RGBA2HexA(...rgb, a)
activeGradient.value = {
color,
offset: position.x,
}
gradients.value.splice(index, 0, activeGradient.value)
}
function onMousemoveGradient(position) {
if (!mousedownGradientPointer) return
activeGradient.value.offset = position.x
gradients.value.sort((a, b) => a.offset - b.offset)
const value = toGradientString(angle.value, gradients.value)
updateValue(value)
}
function onMouseupGradient() {
mousedownGradientPointer = false
}
paletteMoveable = registerMoveableElement(elPalette.value, {
onmousemove: onChangeSL,
onmouseup: onChangeSL,
})
function onChangeSL(position) {
disableChangeHSLA()
const x = position.x * 100
const y = position.y * 100
hsla.s = Math.round(x)
hsla.l = Math.round(100 - y)
elPalettePointer.value.style.left = `${x}%`
elPalettePointer.value.style.top = `${y}%`
}
sliderHuxMoveable = registerMoveableElement(elSliderHux.value, {
onmousemove: onChangeHux,
onmouseup: onChangeHux,
})
function onChangeHux(position) {
disableChangeHSLA()
hsla.h = position.x * 360
elSliderHuxPointer.value.style.left = `${position.x * 100}%`
}
sliderAlphaMoveable = registerMoveableElement(elSliderAlpha.value, {
onmousemove: onChangeAlpha,
onmouseup: onChangeAlpha,
})
function onChangeAlpha(position) {
disableChangeHSLA()
hsla.a = position.x
elSliderAlphaPointer.value.style.left = `${position.x * 100}%`
}
changeMode(mode.value)
recordValue(props.value)
}
onBeforeUnmount(() => {
paletteMoveable?.destroy()
sliderHuxMoveable?.destroy()
sliderAlphaMoveable?.destroy()
unwatchHSLA()
if (gradientMoveable) {
gradientMoveable.destroy()
}
})
function recordValue(value) {
if (mode.value === '纯色') {
record.color = value
} else if (mode.value === '渐变') {
record.gradient = value
} else if (mode.value === '图案') {
record.image = value
}
}
function updateValue(value) {
if (value === props.value) return
recordValue(value)
emit('update:value', value)
emit('change', {
mode: mode.value,
color: value,
angle: Number(angle.value),
stops: gradients.value,
})
}
async function onChangeMode(value) {
if (value === mode.value) return
mode.value = value
let color
if (value === '纯色') {
color = record.color
} else if (value === '渐变') {
color = record.gradient
} else if (value === '图案') {
color = record.image
}
updateValue(color)
}
function changeMode(mode) {
if (mode === '纯色') {
setColor(props.value)
} else if (mode === '渐变') {
if (gradients.value.length === 0) {
props.value.match(/[^,]+/g).forEach((item, index) => {
if (index === 0) {
angle.value = Number(item.match(/\d+/)[0])
return
}
let [color, offset] = item.trim().split(' ')
if (!color.startsWith('#')) color = RGBA2HexA(color)
offset = offset.match(/\d+/)[0] / 100
gradients.value.push({ color, offset })
activeGradient.value = gradients.value[0]
})
} else {
setColor(activeGradient.value.color)
}
}
// TODO:
}
function updateColorData(hexA) {
paletteBackground.value = `hsl(${hsla.h}, 100%, 50%)`
hex.value = hexA.slice(0, 7)
backendHex = hex.value
alpha.value = Math.round((hsla.a ?? 1) * 100)
}
function setColor(color) {
// palette sliderHux sliderAlpha pointer
// hsla update:value
// watch props.value hsla
if (canChangeHSLAPointerPos) {
const _hsla = hexA2HSLA(color)
hsla.h = _hsla[0]
hsla.s = _hsla[1]
hsla.l = _hsla[2]
hsla.a = _hsla[3]
updateColorData(color)
let x = hsla.s
const y = Math.round(100 - hsla.l)
elPalettePointer.value.style.left = `${x}%`
elPalettePointer.value.style.top = `${y}%`
x = hsla.h / 360
elSliderHuxPointer.value.style.left = `${x * 100}%`
elSliderAlphaPointer.value.style.left = `${hsla.a * 100}%`
}
}
function onMousedownGradientPointer(stop) {
mousedownGradientPointer = true
activeGradient.value = stop
}
function onKeyupGradientPointer(event) {
event.stopPropagation()
event.preventDefault()
if (!['Backspace', 'Delete'].includes(event.key)) return
if (gradients.value.length === 2) return
const index = gradients.value.indexOf(activeGradient.value)
gradients.value.splice(index, 1)
activeGradient.value = gradients.value[0]
}
function onChangeHex(value) {
if (/^#(?:[0-9a-f]{3}){1,2}$/i.test(value)) {
const rgb = hex2RGB(value)
const [h, s, l] = RGB2HSL(...rgb)
hsla.h = h
hsla.s = s
hsla.l = l
elPalettePointer.value.style.left = `${hsla.s}%`
elPalettePointer.value.style.top = `${100 - hsla.l}%`
elSliderHuxPointer.value.style.left = `${(hsla.h / 360) * 100}%`
hex.value = value
} else {
// hex.value = backendHex
}
}
function onChangeAlpha(value) {
hsla.a = value / 100
elSliderAlphaPointer.value.style.left = `${value}%`
}
function disableChangeHSLA() {
canChangeHSLAPointerPos = false
if (canChangeHSLAPointerPosTimer) clearTimeout(canChangeHSLAPointerPosTimer)
canChangeHSLAPointerPosTimer = setTimeout(() => {
canChangeHSLAPointerPos = true
}, 16)
}
async function onClickStraw(val) {
let result = ''
if (val && val.target.value) {
const color = val.target.value
result = color + (color.length === 7 ? 'ff' : '')
} else {
const eyeDropper = new window.EyeDropper() // EyeDropper
toolTip('按Esc可退出')
try {
const drop = await eyeDropper.open() //
const colorHexValue = drop.sRGBHex
result = colorHexValue + 'ff'
} catch (e) {
console.log('用户取消了取色')
}
}
if (mode.value === '渐变') {
activeGradient.value.color = result
activeGradient.value = { ...activeGradient.value }
} else {
emit('update:value', result)
}
emit('native-pick', result)
}
const onInputBlur = (e) => {
const fixColor = patchHexColor(e.target.value)
emit('blur', fixColor)
emit('update:value', fixColor)
}
function patchHexColor(str) {
let hex = str.replace(/\s/g, '') //
if (!str.startsWith('#')) {
hex = '#' + hex
}
if (hex.length < 9) {
hex = hex.padEnd(9, 'f')
}
return hex
}
function angleChange() {
updateValue(toGradientString(angle.value, gradients.value))
}
</script>
<style lang="less" scoped>
*,
::before,
::after {
box-sizing: border-box;
border-width: 0;
border-style: solid;
border-color: #e5e7eb;
}
.flex-center {
display: flex;
justify-content: center;
align-items: center;
}
.title {
margin-bottom: 0.75rem;
font-size: 15px;
font-weight: 600;
}
.color-picker {
-webkit-user-select: none;
user-select: none;
min-width: 220px;
}
.cp__gradient {
&-bar {
display: flex;
justify-content: center;
height: 16px;
width: 100%;
padding: 0 8px;
}
}
.cpgb__track {
position: relative;
cursor: pointer;
}
.cpgb__pointer {
cursor: grab;
position: absolute;
top: -0px;
top: -0.125rem;
height: 1.25rem;
--tw-translate-x: -50%;
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
border-width: 2px;
border-style: solid;
--tw-border-opacity: 1;
border-color: rgb(255 255 255 / var(--tw-border-opacity));
--tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
outline: 2px solid transparent;
outline-offset: 2px;
width: 18px;
&--active {
z-index: 1;
border-radius: 3px;
box-shadow: 0 0 4px 0 rgb(0 0 0 / 20%), 0 0 0 1.2px #2254f4;
}
}
.cp__palette {
height: 140px;
position: relative;
margin-top: 0.75rem;
margin-top: 0.875rem;
cursor: pointer;
overflow: hidden;
border-radius: 0.25rem;
.cpp__color-saturation,
.cpp__color-value {
position: absolute;
bottom: 0px;
right: 0px;
top: 0px;
width: 100%;
height: 100%;
}
.cpp__color-saturation {
background-image: linear-gradient(to right, var(--tw-gradient-stops));
--tw-gradient-from: #fff var(--tw-gradient-from-position);
--tw-gradient-to: rgb(255 255 255 / 0) var(--tw-gradient-to-position);
--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
}
.cpp__color-value {
background-image: linear-gradient(to top, var(--tw-gradient-stops));
--tw-gradient-from: #000 var(--tw-gradient-from-position);
--tw-gradient-to: rgb(0 0 0 / 0) var(--tw-gradient-to-position);
--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
}
.cpp__pointer {
position: absolute;
height: 0.75rem;
width: 0.75rem;
--tw-translate-x: -0.25rem;
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
--tw-translate-x: -0.375rem;
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
--tw-translate-y: -0.25rem;
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
border-radius: 9999px;
border-width: 2px;
--tw-border-opacity: 1;
border-color: rgb(255 255 255 / var(--tw-border-opacity));
--tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
}
}
.cp__slider {
position: relative;
margin-top: 0.75rem;
margin-top: 0.875rem;
height: 0.5rem;
border-radius: 0.25rem;
&-hux {
background: linear-gradient(90deg, red 0, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, red);
}
&-alpha {
background: linear-gradient(to top right, hsla(0, 0%, 80%, 0.4) 25%, transparent 0, transparent 75%, hsla(0, 0%, 80%, 0.4) 0, hsla(0, 0%, 80%, 0.4)), linear-gradient(to top right, hsla(0, 0%, 80%, 0.4) 25%, transparent 0, transparent 75%, hsla(0, 0%, 80%, 0.4) 0, hsla(0, 0%, 80%, 0.4));
background-size: 6px 6px;
background-position: 0 0, 3px 3px;
}
.cpsa__background {
box-shadow: inset 0 0 0 1px rgb(0 0 0 / 6%);
height: 100%;
border-radius: 0.25rem;
}
}
.cp__box {
margin-top: 0.75rem;
margin-top: 0.875rem;
display: flex;
.item {
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
margin-left: 6px;
width: 24px;
height: 24px;
box-sizing: border-box;
border-radius: 4px;
}
.item-color {
box-shadow: inset 0 0 0 1px rgb(0 0 0 / 6%);
}
.item:first-of-type {
margin: 0;
}
.item:hover {
transform: scale(1.08);
}
.input {
width: 4.7rem;
margin-left: 2px;
}
.native {
width: 100%;
height: 100%;
}
}
.cps__track {
position: absolute;
left: 0.25rem;
right: 0.25rem;
top: 0px;
}
.cpst__pointer {
cursor: pointer;
box-shadow: 0 0 2px rgb(0 0 0 / 60%);
position: absolute;
top: 0px;
box-sizing: content-box;
height: 0.5rem;
width: 0.5rem;
--tw-translate-x: -0.5rem;
--tw-translate-y: -0.25rem;
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
border-radius: 9999px;
border-width: 4px;
--tw-border-opacity: 1;
border-color: rgb(255 255 255 / var(--tw-border-opacity));
}
</style>

View File

@ -0,0 +1,28 @@
{
"name": "@palxp/color-picker",
"version": "1.5.5",
"description": "TODO",
"author": "ShawnPhang <palxiao@vip.qq.com>",
"homepage": "https://fe-doc.palxp.cn/#/color-picker",
"license": "ISC",
"main": "index.ts",
"module": "index.ts",
"types": "index.d.ts",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/palxiao/front-end-arsenal.git"
},
"scripts": {
"test": "echo \"Error: run tests from root\" && exit 1"
},
"bugs": {
"url": "https://github.com/palxiao/front-end-arsenal/issues"
},
"dependencies": {
"throttle-debounce": "^5.0.0"
},
"gitHead": "fcba4b7113a557f245ea8e8e64170d93bbbe1e57"
}

View File

@ -0,0 +1,130 @@
export const RGB2Hex = (r: number, g: number, b: number) => {
let _r = Math.round(r).toString(16)
let _g = Math.round(g).toString(16)
let _b = Math.round(b).toString(16)
if (_r.length === 1) _r = '0' + _r
if (_g.length === 1) _g = '0' + _g
if (_b.length === 1) _b = '0' + _b
return '#' + _r + _g + _b
}
export const RGBA2HexA = (r: number, g: number, b: number, a = 1) => {
const hex = RGB2Hex(r, g, b)
let _a = Math.round((a as number) * 255).toString(16)
if (_a.length === 1) _a = '0' + _a
return hex + _a
}
export const RGB2HSL = (r: number, g: number, b: number) => {
r /= 255
g /= 255
b /= 255
const minVal = Math.min(r, g, b)
const maxVal = Math.max(r, g, b)
const delta = maxVal - minVal
let h = 0
let s = 0
const l = maxVal
if (delta === 0) {
h = s = 0
} else {
s = delta / maxVal
const dr = ((maxVal - r) / 6 + delta / 2) / delta
const dg = ((maxVal - g) / 6 + delta / 2) / delta
const db = ((maxVal - b) / 6 + delta / 2) / delta
if (r === maxVal) {
h = db - dg
} else if (g === maxVal) {
h = 1 / 3 + dr - db
} else if (b === maxVal) {
h = 2 / 3 + dg - dr
}
if (h < 0) {
h += 1
} else if (h > 1) {
h -= 1
}
}
return [h * 360, s * 100, l * 100]
}
export const RGBA2HSLA = (r: number, g: number, b: number, a = 1) => [...RGB2HSL(r, g, b), a]
export function HSL2RGB(h: number, s: number, l: number) {
h = (h / 360) * 6
s /= 100
l /= 100
const i = Math.floor(h)
const f = h - i
const p = l * (1 - s)
const q = l * (1 - f * s)
const t = l * (1 - (1 - f) * s)
const mod = i % 6
const r = [l, q, p, p, t, l][mod]
const g = [t, l, l, q, p, p][mod]
const b = [p, p, t, l, l, q][mod]
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)]
}
export const HSLA2RGBA = (h: number, s: number, l: number, a = 1) => [...HSL2RGB(h, s, l), a]
export const HSL2Hex = (h: number, s: number, l: number) => {
const [r, g, b] = HSL2RGB(h, s, l)
return RGB2Hex(r, g, b)
}
export const HSLA2HexA = (h: number, s: number, l: number, a = 1) => {
const hex = HSL2Hex(h, s, l)
return `${hex}${a === 0 ? '00' : Math.round(a * 255).toString(16)}`
}
export const hex2RGB = (hex: string) => {
hex = hex.slice(0, 7)
let r = 0
let g = 0
let b = 0
if (hex.length == 4) {
// 3 digits
r = Number('0x' + hex[1] + hex[1])
g = Number('0x' + hex[2] + hex[2])
b = Number('0x' + hex[3] + hex[3])
} else if (hex.length == 7) {
// 6 digits
r = Number('0x' + hex[1] + hex[2])
g = Number('0x' + hex[3] + hex[4])
b = Number('0x' + hex[5] + hex[6])
}
return [r, g, b]
}
export const hexA2RGBA = (hexA: string) => {
const rgb = hex2RGB(hexA)
const a = Number('0x' + hexA[7] + hexA[8])
return [...rgb, Number((a / 255).toFixed(2))]
}
export const hex2HSL = (hex: string) => {
const [r, g, b] = hex2RGB(hex)
return RGB2HSL(r, g, b)
}
export const hexA2HSLA = (hexA: string) => {
const [r, g, b, a] = hexA2RGBA(hexA)
return RGBA2HSLA(r, g, b, a)
}

View File

@ -0,0 +1,58 @@
/*
* @Author: ShawnPhang
* @Date: 2023-04-26 11:30:10
* @Description:
* @LastEditors: ShawnPhang <https://m.palxp.cn>
* @LastEditTime: 2023-11-28 11:03:14
*/
export const parseBackgroundValue = (value: string): string => {
if (value.startsWith('#')) return '纯色'
if (value.startsWith('linear-gradient')) return '渐变'
return '图案'
}
interface Stop {
color: string
offset: number
}
export const toGradientString = (angle: number, stops: Stop[]) => {
const s: string[] = []
stops.forEach((stop) => {
s.push(`${stop.color} ${stop.offset * 100}%`)
})
return `linear-gradient(${angle}deg, ${s.join(',')})`
}
/**
*
* @param content
* @param tooltipVisible
* @returns
*/
export function toolTip(content: string) {
const tooltip = drawTooltip(content)
document.body.appendChild(tooltip)
setTimeout(() => tooltip?.parentNode?.removeChild(tooltip), 2000)
}
function drawTooltip(content: string, tooltipVisible = true) {
const tooltip: any = document.createElement('div')
tooltip.id = 'color-pipette-tooltip-container'
tooltip.innerHTML = content
tooltip.style = `
position: fixed;
left: 50%;
top: 9%;
z-index: 10002;
display: ${tooltipVisible ? 'flex' : 'none'};
align-items: center;
background-color: rgba(0,0,0,0.4);
padding: 6px 12px;
border-radius: 4px;
color: #fff;
font-size: 18px;
pointer-events: none;
`
return tooltip
}

View File

@ -0,0 +1,70 @@
import { toNumber } from './tool'
interface Position {
x: number
y: number
}
interface RegisterMoveablePanelOptions {
wrapEl?: HTMLElement
onmousedown?(position: Position, event: MouseEvent): void
onmousemove?(position: Position, event: MouseEvent): void
onmouseup?(position: Position, event: MouseEvent): void
}
export const registerMoveableElement = (el: HTMLElement, { onmousedown, onmousemove, onmouseup }: RegisterMoveablePanelOptions = {}) => {
let elRect = el.getBoundingClientRect()
const position = { x: 0, y: 0 }
const update = (event: MouseEvent) => {
let dx = event.pageX - elRect.x
let dy = event.pageY - elRect.y
if (dx < 0) dx = 0
if (dx > elRect.width) dx = elRect.width
if (dy < 0) dy = 0
if (dy > elRect.height) dy = elRect.height
position.x = toNumber(dx / elRect.width, { decimal: 2 })
position.y = toNumber(dy / elRect.height, { decimal: 2 })
}
const _onmousemove = (event: MouseEvent) => {
update(event)
if (onmousemove) {
onmousemove(position, event)
}
}
const _onmouseup = (event: MouseEvent) => {
document.removeEventListener('mousemove', _onmousemove)
document.removeEventListener('mouseup', _onmouseup)
if (onmouseup) {
onmouseup(position, event)
}
}
const _onmousedown = (event: MouseEvent) => {
// elRect 可能不准确,这里更新一下
elRect = el.getBoundingClientRect()
update(event)
document.addEventListener('mousemove', _onmousemove)
document.addEventListener('mouseup', _onmouseup)
if (onmousedown) {
onmousedown(position, event)
}
}
el.addEventListener('mousedown', _onmousedown)
return {
destroy() {
el.removeEventListener('mousedown', _onmousedown)
},
}
}

View File

@ -0,0 +1,6 @@
export const toNumber = (n: number, { decimal = 0 } = {}) => {
if (decimal > 0) {
return Number(n.toFixed(decimal))
}
return Math.round(n)
}

View File

@ -0,0 +1,68 @@
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.2.4](https://github.com/palxiao/front-end-arsenal/compare/@palxp/image-extraction@1.2.3...@palxp/image-extraction@1.2.4) (2023-10-08)
### Bug Fixes
* **component:** Matting类型导出 ([02cdcf7](https://github.com/palxiao/front-end-arsenal/commit/02cdcf74dfddcd0e1cd0353f287eacb49d0c3db4))
## [1.2.3](https://github.com/palxiao/front-end-arsenal/compare/@palxp/image-extraction@1.2.2...@palxp/image-extraction@1.2.3) (2023-10-08)
### Bug Fixes
* **component:** Matting类型导出 ([21aa8ad](https://github.com/palxiao/front-end-arsenal/commit/21aa8ad75021056913c0e2cc548c15a073d79e5b))
## [1.2.2](https://github.com/palxiao/front-end-arsenal/compare/@palxp/image-extraction@1.2.1...@palxp/image-extraction@1.2.2) (2023-10-08)
**Note:** Version bump only for package @palxp/image-extraction
## [1.2.1](https://github.com/palxiao/front-end-arsenal/compare/@palxp/image-extraction@1.2.0...@palxp/image-extraction@1.2.1) (2023-10-08)
**Note:** Version bump only for package @palxp/image-extraction
# [1.2.0](https://github.com/palxiao/front-end-arsenal/compare/@palxp/image-extraction@1.1.1...@palxp/image-extraction@1.2.0) (2023-10-08)
### Features
* **component:** 增加隐藏头部 ([406f1c5](https://github.com/palxiao/front-end-arsenal/commit/406f1c5ea0e38489e91a6b36982b773e5aad42d6))
## [1.1.1](https://github.com/palxiao/front-end-arsenal/compare/@palxp/image-extraction@1.1.0...@palxp/image-extraction@1.1.1) (2023-10-08)
**Note:** Version bump only for package @palxp/image-extraction
# 1.1.0 (2023-10-08)
### Features
* **custom:** 增加Matting组件 ([b26b4dd](https://github.com/palxiao/front-end-arsenal/commit/b26b4dddd11a273adeb97104a6aa2707fb8be920))

View File

@ -0,0 +1,342 @@
<template>
<div v-if="hasHeader" class="options-container">
<slot>
<div class="default-wrap">
<div class="option">
<label for="image">选择图片</label>
<input id="image" type="file" accept=".jpg,.png,.gif,.webp" @change="onFileChange" />
</div>
<div class="option">
<span>画笔类型</span>
<label for="fix">修补</label>
<input id="fix" v-model="isErasing" type="radio" :value="false" />
<label for="matting">擦除</label>
<input id="matting" v-model="isErasing" :value="true" type="radio" />
</div>
<div class="option">
<label for="radius">画笔尺寸</label>
<input id="radius" v-model="radius" class="range-input" type="range" :max="RADIUS_SLIDER_MAX" :min="RADIUS_SLIDER_MIN" :step="RADIUS_SLIDER_STEP" />
<span>{{ brushSize }}</span>
</div>
<div class="option">
<label for="hardness">画笔硬度</label>
<input id="hardness" v-model="hardness" class="range-input" type="range" :max="HARDNESS_SLIDER_MAX" :min="HARDNESS_SLIDER_MIN" :step="HARDNESS_SLIDER_STEP" />
<span>{{ hardnessText }}</span>
</div>
<button :class="saveBtnClass" :disabled="cantSave" @click="onDownloadResult">{{ saveBtnText }}</button>
</div>
</slot>
</div>
<div class="board-container">
<div class="matting-wrapper">
<canvas ref="inputCvs" class="matting-board"></canvas>
<img ref="inputCursor" class="matting-cursor" :style="mattingCursorStyle" :src="cursorImage" />
</div>
<div class="matting-wrapper">
<canvas ref="outputCvs" class="result-board"></canvas>
<img class="matting-cursor" :style="mattingCursorStyle" :src="cursorImage" />
</div>
</div>
<a ref="resultLink" :href="resultURL" :download="downloadFileName"></a>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import { useMatting, useMattingBoard } from './composables/use-matting'
import useMattingCursor from './composables/use-matting-cursor'
import { RADIUS_SLIDER_MIN, RADIUS_SLIDER_MAX, RADIUS_SLIDER_STEP, HARDNESS_SLIDER_MAX, HARDNESS_SLIDER_STEP, HARDNESS_SLIDER_MIN, EventType, DEFAULT_MASK_COLOR } from './constants'
import { ref, onMounted, Ref, computed, nextTick, watch, onUnmounted } from 'vue'
import { generateResultImageURL, getLoadedImage } from './helpers/dom-helper'
export default defineComponent({
props: {
//
hasHeader: {
type: Boolean,
default: true,
},
},
emits: ['register'],
setup(props, { emit }) {
const inputCvs: Ref<null | HTMLCanvasElement> = ref(null)
const outputCvs: Ref<null | HTMLCanvasElement> = ref(null)
const resultURL: Ref<string> = ref('')
const resultLink: Ref<null | HTMLAnchorElement> = ref(null)
const generating: Ref<boolean> = ref(false)
const { picFile, isErasing, radius, hardness, brushSize, hardnessText } = useMatting()
const { width, height, inputCtx, inputHiddenCtx, outputCtx, outputHiddenCtx, draggingInputBoard, initialized, mattingSources, transformConfig, inputDrawingCtx } = useMattingBoard({ picFile, isErasing, radius, hardness })
const { cursorImage, mattingCursorStyle, renderOutputCursor } = useMattingCursor({
inputCtx,
isDragging: draggingInputBoard,
isErasing,
radius,
hardness,
})
const downloadFileName = computed(() => (picFile.value ? `matting_${picFile.value.name}` : 'null'))
const cantSave = computed(() => generating.value || !initialized.value)
const saveBtnClass = computed(() => ({ 'save-btn': true, disabled: cantSave.value }))
const saveBtnText = computed(() => (generating.value ? '保存中...' : '保存'))
const onFileChange = (ev: Event) => {
const { files } = ev.target as HTMLInputElement
if (files && files[0] && /.+\.(jpg|jpeg|png|gif|webp)/.test(files[0].name)) {
picFile.value = files[0]
} else {
alert('未选择图片或图片格式不正确(只支持jpg、png、gif、webp), 请重新选择')
}
}
const initLoadImages = (source: string, result: string) => {
nextTick(() => {
fetch(source)
.then((response) => response.blob())
.then((blob) => {
picFile.value = new File([blob], `image_${Math.random()}.jpg`, { type: 'image/jpeg' })
})
.catch((error) => {
console.error('获取图片失败:', error)
})
watch(
() => initialized.value,
async () => {
const image: any = await loadNetImg(result)
outputHiddenCtx.value.drawImage(image, 0, 0)
// TODO ImageBitmap canvas
let canvas = document.createElement('canvas')
canvas.width = image.width
canvas.height = image.height
let ctx: any = canvas.getContext('2d')
ctx.drawImage(image, 0, 0)
let imageData = ctx.getImageData(0, 0, image.width, image.height)
let data = imageData.data
//
for (let i = 0; i < data.length; i += 4) {
if (data[i + 3] > 0) {
//
data[i] = DEFAULT_MASK_COLOR[0] * 255
data[i + 1] = DEFAULT_MASK_COLOR[1] * 255
data[i + 2] = DEFAULT_MASK_COLOR[2] * 255
data[i + 3] = (DEFAULT_MASK_COLOR[3] - 0.15) * 255 // 便
}
}
await nextTick()
setTimeout(async () => {
//
// transformConfig.positionRange = { ...transformConfig.positionRange };
transformConfig.scaleRatio += 0.0001
transformConfig.scaleRatio -= 0.0001
setTimeout(() => {
inputDrawingCtx.putImageData(imageData, 0, 0)
inputHiddenCtx.value.drawImage(inputDrawingCtx.canvas, 0, 0)
console.log('ok')
setTimeout(() => {
transformConfig.scaleRatio += 0.0001
transformConfig.scaleRatio -= 0.0001
}, 100)
}, 100)
}, 100)
},
)
})
}
//
function loadNetImg(src: string) {
return new Promise((resolve) => {
let image = new Image()
image.crossOrigin = 'anonymous'
image.src = src
image.onload = function () {
resolve(image)
}
})
}
function handlechangeRadius(e: any) {
// if (e.code === 'Space') {
// e.preventDefault();
// }
radius.value = Number(radius.value)
if (e.code === 'BracketLeft') {
radius.value > RADIUS_SLIDER_MIN + RADIUS_SLIDER_STEP && (radius.value -= RADIUS_SLIDER_STEP)
} else if (e.code === 'BracketRight') {
radius.value < RADIUS_SLIDER_MAX - RADIUS_SLIDER_STEP && (radius.value += RADIUS_SLIDER_STEP)
}
}
function initContextsAndSize() {
const inputCanvas = inputCvs.value as HTMLCanvasElement
const outputCanvas = outputCvs.value as HTMLCanvasElement
inputCtx.value = inputCanvas.getContext('2d')
outputCtx.value = outputCanvas.getContext('2d')
const { clientWidth, clientHeight } = inputCanvas
width.value = clientWidth
height.value = clientHeight
}
function onDownloadResult() {
if (mattingSources.value && !generating.value) {
generating.value = true
const url = generateResultImageURL(mattingSources.value.orig, outputHiddenCtx.value)
generating.value = false
resultURL.value = url
setTimeout(() => {
resultLink.value?.click()
})
}
}
function getResult() {
if (mattingSources.value && !generating.value) {
return generateResultImageURL(mattingSources.value.orig, outputHiddenCtx.value)
}
}
onMounted(() => {
emit('register', {
isErasing,
onDownloadResult,
onFileChange,
initLoadImages,
radius,
hardness,
brushSize,
hardnessText,
constants: {
RADIUS_SLIDER_MIN,
RADIUS_SLIDER_MAX,
RADIUS_SLIDER_STEP,
HARDNESS_SLIDER_MAX,
HARDNESS_SLIDER_STEP,
HARDNESS_SLIDER_MIN,
},
getResult,
})
initContextsAndSize()
renderOutputCursor()
document.addEventListener('keydown', handlechangeRadius, false)
})
onUnmounted(() => {
document.removeEventListener('keydown', handlechangeRadius, false)
})
return {
onDownloadResult,
onFileChange,
inputCvs,
outputCvs,
resultURL,
resultLink,
isErasing,
radius,
hardness,
brushSize,
hardnessText,
saveBtnText,
downloadFileName,
saveBtnClass,
cursorImage,
mattingCursorStyle,
cantSave,
RADIUS_SLIDER_MIN,
RADIUS_SLIDER_MAX,
RADIUS_SLIDER_STEP,
HARDNESS_SLIDER_MAX,
HARDNESS_SLIDER_STEP,
HARDNESS_SLIDER_MIN,
}
},
})
</script>
<style lang="less" scoped>
.options-container {
height: 50px;
.default-wrap {
display: flex;
height: 100%;
padding: 0 12px;
align-items: center;
}
.option {
display: flex;
align-items: center;
padding: 0 10px;
height: 100%;
&:not(:last-child) {
border-right: 1px solid #e3e7e9;
}
.range-input {
position: relative;
top: 2px;
}
}
.save-btn {
position: absolute;
right: 20px;
font-size: 16px;
cursor: pointer;
&.disabled {
cursor: not-allowed;
}
}
}
.board-container {
// position: fixed;
top: 50px;
bottom: 0;
left: 0;
width: 100%;
min-width: 800px;
min-height: 600px;
display: flex;
.matting-board,
.result-board {
flex: 1 50%;
border: 1px solid #c3c7c9;
background: #e3e7e9;
background-image: linear-gradient(45deg, #f6fafc 25%, transparent 0), linear-gradient(45deg, transparent 75%, #f6fafc 0), linear-gradient(45deg, #f6fafc 25%, transparent 0), linear-gradient(45deg, transparent 75%, #f6fafc 0);
background-position: 0 0, 12px 12px, 12px 12px, 24px 24px;
background-size: 24px 24px;
}
.matting-wrapper {
position: relative;
flex: 1 1;
}
.matting-board,
.result-board {
width: 100%;
height: 100%;
}
.matting-board {
cursor: none;
}
.result-board {
cursor: grab;
}
.matting-cursor {
/** 穿透画笔,触发画布点击事件 */
pointer-events: none;
display: none;
position: absolute;
left: -9999px;
top: -9999px;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
}
</style>

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 科学家丶
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,29 @@
<!--
* @Author: ShawnPhang
* @Date: 2023-10-07 23:50:21
* @Description:
* @LastEditors: ShawnPhang <https://m.palxp.cn>
* @LastEditTime: 2023-10-08 16:02:58
-->
<img style="display: inline-block;" src="https://img.shields.io/github/watchers/palxiao/front-end-arsenal?style=social" /> <img style="display: inline-block;" src="https://img.shields.io/github/forks/palxiao/front-end-arsenal?style=social" /> <img style="display: inline-block;" src="https://img.shields.io/github/stars/palxiao/front-end-arsenal?style=social" />
# image-extraction
> TODO:
<img style="display: inline-block;" src="https://img.shields.io/npm/v/@palxp/image-extraction" /> <img style="display: inline-block;" src="https://img.shields.io/bundlephobia/min/@palxp/image-extraction?color=%2344cc88" /> <img style="display: inline-block;" src="https://img.shields.io/npm/dm/@palxp/image-extraction" />
## Usage
```
yarn add @palxp/image-extraction
import image-extraction from '@palxp/image-extraction'
```
## API
[API Docs 链接](/#/docs)
<iframe src="/#/docs/image-extraction/-image-extraction?preview=true" frameborder="0"></iframe>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,77 @@
import { transformedDrawImage } from '../helpers/dom-helper'
import { initDrawingListeners } from '../helpers/init-drawing-listeners'
import { initDragListener, initScaleListener } from '../helpers/init-transform-listener'
import { BoardRect } from '../types/common'
import { ImageSources, MattingProps, UseInitListenersConfig } from '../types/init-matting'
import { onBeforeUnmount, watch, watchEffect } from 'vue'
export function useInitDrawingListeners(props: MattingProps, config: UseInitListenersConfig) {
const { radius, hardness, isErasing } = props
const { boardContexts, transformConfig, mattingSources, draggingInputBoard, initialized, boardRect, listenerManager } = config
const { inputCtx } = boardContexts
watchEffect(() => {
if (initialized.value) {
initDrawingListeners({
listenerManager,
imageSources: mattingSources.value as ImageSources,
boardContexts,
initDrawingConfig: { radius, hardness, transformConfig },
isErasing: isErasing.value,
draggingInputBoard: draggingInputBoard.value,
boardRect: boardRect.value as BoardRect,
})
}
})
onBeforeUnmount(() => {
listenerManager.removeMouseListeners((inputCtx.value as CanvasRenderingContext2D).canvas)
})
}
export function useInitTransformListener(config: UseInitListenersConfig) {
const { boardContexts, initialized, draggingInputBoard, transformConfig, isDrawing, listenerManager } = config
const { inputCtx, inputHiddenCtx, outputCtx, outputHiddenCtx } = boardContexts
watch(
[initialized, draggingInputBoard, isDrawing],
() => {
if (initialized.value && !isDrawing.value) {
const initConfig = {
inputContexts: { ctx: inputCtx.value as CanvasRenderingContext2D, hiddenCtx: inputHiddenCtx.value },
outputContexts: { ctx: outputCtx.value as CanvasRenderingContext2D, hiddenCtx: outputHiddenCtx.value },
draggingInputBoard: draggingInputBoard.value,
listenerManager,
transformConfig,
}
initDragListener(initConfig)
initScaleListener(initConfig)
// 触发重新绑定绘制监听器,必须输入画板拖动结束时才能重新绑定,否则绘制监听器会覆盖拖动监听器
if (!draggingInputBoard.value) {
transformConfig.positionRange = { ...transformConfig.positionRange }
}
}
},
{ deep: true },
)
watch([transformConfig], async () => {
if (initialized.value) {
const { positionRange, scaleRatio } = transformConfig
const commonConfig = { positionRange, scaleRatio }
transformedDrawImage({
ctx: inputCtx.value as CanvasRenderingContext2D,
hiddenCtx: inputHiddenCtx.value,
...commonConfig,
})
transformedDrawImage({
ctx: outputCtx.value as CanvasRenderingContext2D,
hiddenCtx: outputHiddenCtx.value,
withBorder: true,
...commonConfig,
})
}
})
onBeforeUnmount(() => {
if (initialized.value) {
listenerManager.removeMouseListeners((outputCtx.value as CanvasRenderingContext2D).canvas)
listenerManager.removeWheelListeners()
}
})
}

View File

@ -0,0 +1,62 @@
import { EventType, UPDATE_BOARDRECT_DEBOUNCE_TIME } from '../constants'
import { resizeCanvas } from '../helpers/dom-helper'
import { computeBoardRect } from '../helpers/init-compute'
import { initMatting } from '../helpers/init-matting'
import { MattingProps, UseInitMattingBoardsConfig } from '../types/init-matting'
import { debounce } from 'throttle-debounce'
import { onMounted, onUnmounted, watch } from 'vue'
export function useInitMattingBoards(props: MattingProps, useInitMattingBoardsConfig: UseInitMattingBoardsConfig) {
const { picFile } = props
const {
boardContexts,
boardContexts: { inputCtx, outputCtx, inputHiddenCtx, outputHiddenCtx },
} = useInitMattingBoardsConfig
const { initMattingResult, width, height, initialized } = useInitMattingBoardsConfig
const { boardRect, transformConfig, mattingSources } = useInitMattingBoardsConfig
const updateBoardRect = () => {
boardRect.value = computeBoardRect((inputCtx.value as CanvasRenderingContext2D).canvas)
}
const resizeBoards = () => {
requestAnimationFrame(() => {
const commonConfig = { targetHeight: height.value, targetWidth: width.value, transformConfig }
resizeCanvas({
ctx: inputCtx.value as CanvasRenderingContext2D,
hiddenCtx: inputHiddenCtx.value,
...commonConfig,
})
resizeCanvas({
ctx: outputCtx.value as CanvasRenderingContext2D,
hiddenCtx: outputHiddenCtx.value,
withBorder: true,
...commonConfig,
})
})
}
watch([picFile], async () => {
if (picFile.value && width.value && height.value) {
initialized.value = false
initMattingResult.value = await initMatting({
boardContexts,
picFile: picFile.value,
targetSize: { width: width.value, height: height.value },
transformConfig: {},
imageSources: {},
})
const { raw, mask, orig, positionRange, scaleRatio } = initMattingResult.value
transformConfig.positionRange = positionRange
transformConfig.scaleRatio = scaleRatio
mattingSources.value = { raw, mask, orig }
updateBoardRect()
resizeBoards()
initialized.value = true
}
})
onMounted(() => {
window.addEventListener(EventType.Resize, resizeBoards)
window.addEventListener('scroll', debounce(UPDATE_BOARDRECT_DEBOUNCE_TIME, updateBoardRect))
})
onUnmounted(() => {
window.removeEventListener(EventType.Resize, resizeBoards)
})
}

View File

@ -0,0 +1,111 @@
import { ERASE_POINT_INNER_COLOR, ERASE_POINT_OUTER_COLOR, EventType, INITIAL_HARDNESS, INITIAL_RADIUS, REPAIR_POINT_INNER_COLOR, REPAIR_POINT_OUTER_COLOR } from '../constants'
import { drawBrushPoint, getLoadedImage } from '../helpers/dom-helper'
import { DrawingCircularConfig } from '../types/dom'
import { computed, reactive, ref, Ref, UnwrapRef, watch, watchEffect } from 'vue'
import iconEraser from '../assets/eraser.png'
import { CursorStyle, UseCursorConfig } from '../types/cursor'
export class MattingCursor {
ctx: CanvasRenderingContext2D
cursorImage: Ref<string | undefined> = ref('')
inputCursorStyle: Ref<CursorStyle | null> = ref(null)
mattingCursorStyle: UnwrapRef<CursorStyle> = reactive(Object.create(null))
radius = ref(INITIAL_RADIUS)
hardness = ref(INITIAL_HARDNESS)
inputCanvas = computed(() => (this.inputCtx.value as CanvasRenderingContext2D).canvas as HTMLElement)
pointInnerColor = computed(() => (this.isErasing.value ? ERASE_POINT_INNER_COLOR : REPAIR_POINT_INNER_COLOR))
pointOuterColor = computed(() => (this.isErasing.value ? ERASE_POINT_OUTER_COLOR : REPAIR_POINT_OUTER_COLOR))
constructor(private inputCtx: Ref<CanvasRenderingContext2D | null>, private isErasing: Ref<boolean>) {
this.ctx = this.creatCursorCanvas()
this
}
creatCursorCanvas() {
const ctx = document.createElement('canvas').getContext('2d') as CanvasRenderingContext2D
return this.updateCtx(ctx)
}
updateCtx(ctx: CanvasRenderingContext2D): CanvasRenderingContext2D {
ctx.canvas.width = this.radius.value * 2
ctx.canvas.height = this.radius.value * 2
return ctx
}
async createCursorImage() {
this.ctx = this.updateCtx(this.ctx)
const drawingConfig: DrawingCircularConfig = {
ctx: this.ctx as CanvasRenderingContext2D,
x: this.radius.value,
y: this.radius.value,
radius: this.radius.value,
hardness: this.hardness.value,
innerColor: this.pointInnerColor.value,
outerColor: this.pointOuterColor.value,
}
drawBrushPoint(drawingConfig)
await this.drawIcon()
return await this.ctx.canvas.toDataURL()
}
async drawIcon() {
if (this.isErasing.value) {
const eraser = await getLoadedImage(iconEraser)
this.ctx.drawImage(eraser, 0, 0, this.radius.value * 2, this.radius.value * 2)
}
}
renderOutputCursor() {
const target = this.inputCanvas.value
target.addEventListener(EventType.Mouseover, this.onShowCursor.bind(this))
target.addEventListener(EventType.Mousemove, this.onRenderOutputCursor.bind(this))
target.addEventListener(EventType.Mouseout, this.onHideCursor.bind(this))
}
onShowCursor() {
this.mattingCursorStyle.display = 'initial'
}
onHideCursor() {
this.mattingCursorStyle.display = 'none'
}
onRenderOutputCursor(e: MouseEvent) {
this.mattingCursorStyle.left = e.offsetX - this.radius.value + 'px'
this.mattingCursorStyle.top = e.offsetY - this.radius.value + 'px'
}
changeOutputCursorByDrag([isDragging]: boolean[]) {
if (isDragging) {
this.onHideCursor()
} else {
this.onShowCursor()
}
}
updateCursorParams(currHardness: number, currRadius: number) {
this.hardness.value = currHardness
this.radius.value = currRadius
}
}
export default function useMattingCursor(config: UseCursorConfig) {
const { inputCtx, isDragging, isErasing, hardness, radius } = config
const mattingCursor = new MattingCursor(inputCtx, isErasing)
const { cursorImage, mattingCursorStyle, renderOutputCursor } = mattingCursor
watchEffect(async () => {
mattingCursor.updateCursorParams(hardness.value, radius.value)
cursorImage.value = await mattingCursor.createCursorImage()
})
watch([isDragging], mattingCursor.changeOutputCursorByDrag.bind(mattingCursor))
return {
mattingCursor,
mattingCursorStyle,
cursorImage,
renderOutputCursor: renderOutputCursor.bind(mattingCursor),
}
}

View File

@ -0,0 +1,78 @@
/*
* @Author: ShawnPhang
* @Date: 2023-10-05 16:33:07
* @Description:
* @LastEditors: ShawnPhang <https://m.palxp.cn>
* @LastEditTime: 2023-10-08 11:09:17
*/
import { INITIAL_RADIUS, INITIAL_HARDNESS, RADIUS_TO_BRUSH_SIZE_RATIO, HARDNESS_ZOOM_TO_SLIDER_RATIO, INITIAL_TRANSFORM_CONFIG } from '../constants'
import { createContext2D } from '../helpers/dom-helper'
import ListenerManager from '../helpers/listener-manager'
import { BoardRect, TransformConfig } from '../types/common'
import { ImageSources, InitMattingBaseConfig, InitMattingResult, MattingProps } from '../types/init-matting'
import { ref, computed, Ref, reactive } from 'vue'
import { useInitDrawingListeners, useInitTransformListener } from './use-init-listeners'
import { useInitMattingBoards } from './use-init-matting'
export function useMatting() {
const picFile = ref<null | File>(null)
const isErasing = ref(false)
const radius = ref(INITIAL_RADIUS)
const hardness = ref(INITIAL_HARDNESS)
const brushSize = computed(() => radius.value * RADIUS_TO_BRUSH_SIZE_RATIO)
const hardnessText = computed(() => `${Math.round((hardness.value as number) * HARDNESS_ZOOM_TO_SLIDER_RATIO)}%`)
return {
picFile,
isErasing,
radius,
hardness,
brushSize,
hardnessText,
}
}
const inputDrawingCtx: CanvasRenderingContext2D = createContext2D()
const outputDrawingCtx: CanvasRenderingContext2D = createContext2D()
export function useMattingBoard(props: MattingProps) {
const width = ref(0)
const height = ref(0)
const inputCtx: Ref<CanvasRenderingContext2D | null> = ref(null)
const outputCtx: Ref<CanvasRenderingContext2D | null> = ref(null)
const initMattingResult: Ref<InitMattingResult | null> = ref(null)
const draggingInputBoard = ref(false)
const isDrawing = ref(false)
const transformConfig: TransformConfig = reactive(INITIAL_TRANSFORM_CONFIG)
const mattingSources: Ref<ImageSources | null> = ref(null)
const boardRect: Ref<BoardRect | null> = ref(null)
const initialized = ref(false)
const inputHiddenCtx = ref(createContext2D())
const outputHiddenCtx = ref(createContext2D())
const listenerManager = new ListenerManager()
const initMattingConfig: InitMattingBaseConfig = {
boardContexts: { inputCtx, outputCtx, inputDrawingCtx, outputDrawingCtx, inputHiddenCtx, outputHiddenCtx },
initMattingResult,
transformConfig,
mattingSources,
initialized,
boardRect,
}
const initListenersConfig = { ...initMattingConfig, draggingInputBoard, isDrawing, listenerManager }
useInitMattingBoards(props, { ...initMattingConfig, width, height })
useInitDrawingListeners(props, initListenersConfig)
useInitTransformListener(initListenersConfig)
return {
width,
height,
inputCtx,
outputCtx,
inputHiddenCtx,
outputHiddenCtx,
draggingInputBoard,
transformConfig,
initialized,
mattingSources,
inputDrawingCtx,
}
}

View File

@ -0,0 +1,122 @@
import { GapSize, RectSize, TransformConfig } from '../types/common'
import { GLColor } from '../types/matting-drawing'
export enum EventType {
Mouseover = 'mouseover',
Mouseenter = 'mouseenter',
Mouseout = 'mouseout',
Mouseleave = 'mouseleave',
Mouseup = 'mouseup',
Mousemove = 'mousemove',
MouseDown = 'mousedown',
DblClick = 'dblclick',
Click = 'click',
ContextMenu = 'contextmenu',
KeyDown = 'keydown',
Keyup = 'keyup',
Keypress = 'keypress',
Scroll = 'scroll',
Resize = 'resize',
Wheel = 'wheel',
UndoRedoStateChanged = 'undoRedoStateChanged',
}
export const INITIAL_RADIUS = 12.5
export const INITIAL_HARDNESS = 0.5
/** */
export const RADIUS_TO_BRUSH_SIZE_RATIO = 4
export const RADIUS_SLIDER_MIN = 0.25
export const RADIUS_SLIDER_STEP = 0.25
export const RADIUS_SLIDER_MAX = 25
/** 画笔绘制点最小半径像素 */
export const MIN_RADIUS = 0.5
export const HARDNESS_SLIDER_MIN = 0
export const HARDNESS_SLIDER_STEP = 0.01
export const HARDNESS_SLIDER_MAX = 1
/** 硬度放大到滑动条显示的值范围的放大倍数 */
export const HARDNESS_ZOOM_TO_SLIDER_RATIO = 100
export const INITIAL_SCALE_RATIO = 1
/** 默认的变换配置对象 */
export const INITIAL_TRANSFORM_CONFIG: TransformConfig = {
scaleRatio: INITIAL_SCALE_RATIO,
positionRange: {
minX: 0,
maxX: 0,
minY: 0,
maxY: 0,
},
}
/**
*
* 80px40px
* 80px时40px时80px为准
*/
export const INITIAL_GAP_SIZE: GapSize = {
horizontal: 40,
vertical: 80,
}
/** 隐藏画板的间隙对象——隐藏画板不需要留白 */
export const HIDDEN_BOARD_GAP_SIZE: GapSize = {
horizontal: 0,
vertical: 0,
}
/** 隐藏画板的最大尺寸——默认情况下与图片原始尺寸一致但不能超过2000px,超过2000px会进行缩放以免影响性能 */
export const HIDDEN_BOARD_MAX_SIZE: RectSize = {
width: 2000,
height: 2000,
}
/** 默认的图像平滑选项值 */
export const DEFAULT_IMAGE_SMOOTH_CHOICE = false
export const IMAGE_BORDER_STYLE = '#000000'
export const INITIAL_IMAGE_BORDER_WIDTH = 1
export const DEFAULT_MASK_COLOR: GLColor = [0.47, 0.42, 0.9, 0.5]
/** 窗口滚动时更新boardRect的节流等待时间 */
export const UPDATE_BOARDRECT_DEBOUNCE_TIME = 100
/** 计算stepBase(绘制补帧线条的迭代中的增量,基于真实尺寸的半径得到)的系数的倒数 */
export const DRAWING_STEP_BASE_BASE = 20
/** 计算绘制圆点的节流步长的系数的倒数 */
export const DRAWING_STEP_BASE = 3.5
export const GLOBAL_COMPOSITE_OPERATION_SOURCE_OVER = 'source-over'
export const GLOBAL_COMPOSITE_OPERATION_DESTINATION_IN = 'destination-in'
export const GLOBAL_COMPOSITE_OPERATION_DESTINATION_OUT = 'destination-out'
/** 计算绘制补帧线条的节流步长的系数的倒数 */
export const DRAW_INTERPOLATION_STEP_BASE = 2.5
/** 绘制补帧线条的画笔半径阈值 */
export const DRAW_INTERPOLATION_RADIUS_THRESHOLD = 1
/** 径向渐变开始圆形的半径 */
export const GRADIENT_INNER_RADIUS = 0
/** 渐变开始的偏移值 */
export const GRADIENT_BEGIN_OFFSET = 0
/** 渐变结束的偏移值 */
export const GRADIENT_END_OFFSET = 1
/** 修补渐变开始的颜色 */
export const REPAIR_POINT_INNER_COLOR = 'rgba(119,106,230,1)'
/** 修补渐变结束的颜色 */
export const REPAIR_POINT_OUTER_COLOR = 'rgba(119,106,230,0)'
/** 擦除渐变开始的颜色 */
export const ERASE_POINT_INNER_COLOR = 'rgba(255,255,255,1)'
/** 擦除结束的颜色 */
export const ERASE_POINT_OUTER_COLOR = 'rgba(255,255,255,0)'
/** 0° */
export const ZERO_DEGREES = 0
/** 360° */
export const ONE_TURN_DEGREES = Math.PI * 2
/** 执行前进后退动作时的防抖时间 */
export const ACTION_THROTTLE_TIME = 300
/** 放大的系数 */
export const ZOOM_IN_COEFFICIENT = 1
/** 缩小的系数 */
export const ZOOM_OUT_COEFFICIENT = -1
/** 缩放比率变化的步长 */
export const SCALE_STEP = 0.04
export const MIN_SCALE_RATIO = 0.15
export const MAX_SCALE_RATIO = 10

8
packages/image-extraction/env.d.ts vendored Normal file
View File

@ -0,0 +1,8 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import { DefineComponent } from 'vue'
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types
const component: DefineComponent<{}, {}, any>
export default component
}

View File

@ -0,0 +1,186 @@
import { DEFAULT_IMAGE_SMOOTH_CHOICE, GLOBAL_COMPOSITE_OPERATION_DESTINATION_IN, GRADIENT_BEGIN_OFFSET, GRADIENT_END_OFFSET, GRADIENT_INNER_RADIUS, IMAGE_BORDER_STYLE, INITIAL_IMAGE_BORDER_WIDTH, ONE_TURN_DEGREES, REPAIR_POINT_INNER_COLOR, REPAIR_POINT_OUTER_COLOR, ZERO_DEGREES } from '../constants'
import { PositionRange } from '../types/common'
import { CreateContext2DConfig, DrawImageLineBorderConfig, DrawingCircularConfig, GetImageSourceConfig, InitHiddenBoardConfig, InitHiddenBoardWithImageConfig, ResizeCanvasConfig, TransformedDrawingImageConfig } from '../types/dom'
// import { isString } from 'lodash'
function isString(value: string) {
return typeof value === 'string'
}
export function resizeCanvas(config: ResizeCanvasConfig) {
const { ctx, targetWidth, targetHeight, hiddenCtx, transformConfig, withBorder = false } = config
const { positionRange, scaleRatio } = transformConfig
ctx.canvas.width = targetWidth
ctx.canvas.height = targetHeight
ctx.imageSmoothingEnabled = DEFAULT_IMAGE_SMOOTH_CHOICE
transformedDrawImage({ ctx, hiddenCtx, positionRange, scaleRatio, withBorder, clearOld: false })
}
/** 创建2D绘制上下文 */
export function createContext2D(createConfig: CreateContext2DConfig = {}): CanvasRenderingContext2D {
const { targetSize, cloneCanvas } = createConfig
const canvas: HTMLCanvasElement = document.createElement('canvas')
const context2D: CanvasRenderingContext2D = canvas.getContext('2d') as CanvasRenderingContext2D
if (targetSize) {
canvas.width = targetSize.width
canvas.height = targetSize.height
}
if (cloneCanvas) {
domHelpers.copyImageInCanvas(context2D, cloneCanvas)
}
return context2D
}
/** 复制画布中的图像 */
function copyImageInCanvas(hiddenContext: CanvasRenderingContext2D, cloneCanvas: HTMLCanvasElement) {
hiddenContext.canvas.width = cloneCanvas.width
hiddenContext.canvas.height = cloneCanvas.height
hiddenContext.drawImage(cloneCanvas, 0, 0)
}
/** 隐藏各个画布 */
export function hideCanvas(...ctxs: CanvasRenderingContext2D[]) {
for (const ctx of ctxs) {
ctx.canvas.style.display = 'none'
}
}
/** 显示各个画布 */
export function showCanvas(...ctxs: CanvasRenderingContext2D[]) {
for (const ctx of ctxs) {
ctx.canvas.style.display = 'initial'
}
}
/** 获取指定链接下的位图图像 */
export async function getLoadedImage(picFile: File | string): Promise<ImageBitmap> {
const img = new Image()
img.crossOrigin = 'anonymous'
img.src = isString(picFile) ? picFile : URL.createObjectURL(picFile)
await new Promise<void>((resolve) => {
img.onload = () => resolve()
})
return createImageBitmap(img)
}
export function initHiddenBoardWithSource(initConfig: InitHiddenBoardWithImageConfig) {
initHiddenBoard(initConfig)
const {
hiddenCtx: ctx,
imageSource,
targetSize: { width, height },
} = initConfig
return getImageSourceFromCtx({ ctx, imageSource, width, height })
}
/** 初始化隐藏的绘制画板和成果图画板 */
export function initHiddenBoard(initConfig: InitHiddenBoardConfig): void {
const { targetSize, hiddenCtx, drawingCtx } = initConfig
const { width, height } = targetSize
hiddenCtx.canvas.width = width
hiddenCtx.canvas.height = height
drawingCtx.canvas.width = width
drawingCtx.canvas.height = height
}
/** 获取画布全屏绘制后的图像 */
export function getImageSourceFromCtx(config: GetImageSourceConfig) {
const { ctx, imageSource, width, height } = config
ctx.drawImage(imageSource, 0, 0, width, height)
return createImageBitmap(ctx.canvas)
}
/** 清除画布中之前的内容 */
function clearCanvas(ctx: CanvasRenderingContext2D) {
const {
canvas: { width, height },
} = ctx
ctx.clearRect(0, 0, width, height)
}
/** 绘制抠图成果图的线框 */
function drawImageBorder(borderConfig: DrawImageLineBorderConfig) {
const { ctx, lineStyle, lineWidth, positionRange } = borderConfig
const { minY: top, maxX: right, maxY: bottom, minX: left } = positionRange
ctx.imageSmoothingEnabled = !DEFAULT_IMAGE_SMOOTH_CHOICE
ctx.fillStyle = lineStyle
ctx.fillRect(left, top, right - left + lineWidth, lineWidth)
ctx.fillRect(left, bottom, right - left + lineWidth, lineWidth)
ctx.fillRect(left, top + lineWidth, lineWidth, bottom - top - lineWidth)
ctx.fillRect(right, top + lineWidth, lineWidth, bottom - top - lineWidth)
ctx.imageSmoothingEnabled = DEFAULT_IMAGE_SMOOTH_CHOICE
}
/** 绘制画板上图像的边框 */
function drawBoardImageBorder(ctx: CanvasRenderingContext2D, hiddenCtx: CanvasRenderingContext2D) {
const { width, height } = hiddenCtx.canvas
const positionRange: PositionRange = { minX: 0, minY: 0, maxX: width, maxY: height }
drawImageBorder({
ctx,
positionRange,
lineStyle: IMAGE_BORDER_STYLE,
lineWidth: INITIAL_IMAGE_BORDER_WIDTH,
})
}
/** 进行变换和绘制 */
export function transformedDrawImage(transformedConfig: TransformedDrawingImageConfig) {
const { ctx, positionRange, scaleRatio, hiddenCtx } = transformedConfig
const { clearOld = true, withBorder } = transformedConfig
const { minX: translateX, minY: translateY } = positionRange
if (clearOld) {
clearCanvas(ctx)
}
ctx.save()
ctx.translate(translateX, translateY)
ctx.scale(scaleRatio, scaleRatio)
ctx.drawImage(hiddenCtx.canvas, 0, 0)
if (withBorder) {
drawBoardImageBorder(ctx, hiddenCtx)
}
ctx.restore()
}
/** 绘制擦补画笔圆点 */
export function drawBrushPoint(drawingConfig: DrawingCircularConfig) {
const { ctx, x, y, radius, hardness } = drawingConfig
const { innerColor = REPAIR_POINT_INNER_COLOR, outerColor = REPAIR_POINT_OUTER_COLOR } = drawingConfig
ctx.beginPath()
const gradient = ctx.createRadialGradient(x, y, GRADIENT_INNER_RADIUS, x, y, radius)
gradient.addColorStop(GRADIENT_BEGIN_OFFSET, innerColor)
gradient.addColorStop(hardness, innerColor)
gradient.addColorStop(GRADIENT_END_OFFSET, outerColor)
ctx.fillStyle = gradient
ctx.arc(x, y, radius, ZERO_DEGREES, ONE_TURN_DEGREES)
ctx.fill()
}
/** 生成结果图像的URL */
export function generateResultImageURL(rawImage: ImageBitmap, resultCtx: CanvasRenderingContext2D) {
const resultImageCtx = createResultImageContext2D(rawImage, resultCtx)
return resultImageCtx.canvas.toDataURL()
}
/** 创建绘制了原始尺寸结果图的绘制上下文 */
function createResultImageContext2D(imageSource: ImageBitmap, resultImageCtx: CanvasRenderingContext2D): CanvasRenderingContext2D {
const context2D = createRawImageContext2D(imageSource)
drawResultImageInContext2D(context2D, resultImageCtx, imageSource)
return context2D
}
/** 创建绘制了原始尺寸图片的绘制上下文 */
function createRawImageContext2D(imageSource: ImageBitmap): CanvasRenderingContext2D {
const context2D = createContext2D({ targetSize: imageSource })
context2D.drawImage(imageSource, 0, 0)
return context2D
}
/** 在传入的绘制上下文上绘制原始尺寸的结果图 */
function drawResultImageInContext2D(ctx: CanvasRenderingContext2D, resultImageCtx: CanvasRenderingContext2D, imageSource: ImageBitmap): void {
ctx.globalCompositeOperation = GLOBAL_COMPOSITE_OPERATION_DESTINATION_IN
ctx.drawImage(resultImageCtx.canvas, 0, 0, imageSource.width, imageSource.height)
}
export const domHelpers = {
copyImageInCanvas,
}

View File

@ -0,0 +1,57 @@
import { DRAW_INTERPOLATION_RADIUS_THRESHOLD, DRAW_INTERPOLATION_STEP_BASE } from '../constants'
import { MouseMovements } from '../types/common'
import { ComputedMovements, InImageRangeConfig, InterpolationStep, RenderInterpolationConfig } from '../types/drawing'
const { sign, abs, max } = Math
/** 判断坐标位置是否在绘制的图像的范围内 */
export function isInImageRange(inRangeConfig: InImageRangeConfig): boolean {
const { x, y, minX, maxX, minY, maxY } = inRangeConfig
return x >= minX && x <= maxX && y >= minY && y <= maxY
}
/** 计算x/y轴方向移动距离及水平/垂直方向上的最大移动距离 */
export function computeMovements(movements: MouseMovements): ComputedMovements {
const { movementX, movementY } = movements
const unsignedMovementX = abs(movementX)
const unsignedMovementY = abs(movementY)
const maxMovement = max(unsignedMovementX, unsignedMovementY)
return { unsignedMovementX, unsignedMovementY, maxMovement }
}
/** 是否需要插值渲染 */
export function needDrawInterpolation(maxMovement: number, radius: number): boolean {
return radius > DRAW_INTERPOLATION_RADIUS_THRESHOLD && maxMovement > radius / DRAW_INTERPOLATION_STEP_BASE
}
/** 计算插值的步长 */
export function computeInterpolationStep(interpolationConfig: RenderInterpolationConfig): InterpolationStep {
const { drawingConfig, unsignedMovementX, unsignedMovementY, maxMovement } = interpolationConfig
const { movementX, movementY, stepBase } = drawingConfig
const rawStepX = computePivotRawStep(movementX, stepBase)
const rawStepY = computePivotRawStep(movementY, stepBase)
const movementXIsMaximum = isMaxMovement(unsignedMovementX, maxMovement)
const stepX = computePivotStep(movementXIsMaximum, rawStepX, unsignedMovementX, unsignedMovementY)
const stepY = computePivotStep(!movementXIsMaximum, rawStepY, unsignedMovementY, unsignedMovementX)
return { stepX, stepY }
}
/** 计算x/y轴方向上朝上一次鼠标指针位置的插值步长 */
function computePivotRawStep(pivotMovement: number, stepBase: number) {
return -sign(pivotMovement) * stepBase
}
/** 是否为最大移动距离 */
function isMaxMovement(pivotMovement: number, maxMovement: number) {
return pivotMovement === maxMovement
}
/** 计算x/y轴方向的累加步长 */
function computePivotStep(isMaxMovement: boolean, rawStepOfPivot: number, unsignedPivotMovement: number, unsignedCrossedMovement: number) {
return isMaxMovement ? rawStepOfPivot : (unsignedPivotMovement / unsignedCrossedMovement) * rawStepOfPivot
}
/** 判断是否需要绘制插值圆点 */
export function needDrawInterpolationPoint(movement: number, moved: number, step: number) {
return movement - moved > step
}

View File

@ -0,0 +1,105 @@
/*
* @Author: ShawnPhang
* @Date: 2023-10-05 16:33:07
* @Description:
* @LastEditors: ShawnPhang <https://m.palxp.cn>
* @LastEditTime: 2023-10-08 11:09:55
*/
import { GLOBAL_COMPOSITE_OPERATION_DESTINATION_IN, GLOBAL_COMPOSITE_OPERATION_SOURCE_OVER } from '../constants'
import { MattingDrawingConfig, RenderInterpolationConfig } from '../types/drawing'
import { createContext2D, drawBrushPoint, transformedDrawImage } from './dom-helper'
import { computeInterpolationStep, computeMovements, needDrawInterpolation, needDrawInterpolationPoint } from './drawing-compute'
import { fixed } from './util'
/** 批量执行抠图(修补/擦除)绘制 */
export function executeMattingDrawing(drawingConfigs: MattingDrawingConfig[]) {
for (const config of drawingConfigs) {
const { radius } = config
const { maxMovement, unsignedMovementX, unsignedMovementY } = computeMovements(config)
if (needDrawInterpolation(maxMovement, radius)) {
renderMattingInterpolation({ drawingConfig: config, unsignedMovementX, unsignedMovementY, maxMovement })
} else {
drawMattingPoint(config)
}
drawResultArea(config)
}
}
/** 隐藏的辅助插值绘制的绘制上下文对象 */
const interpolationCtx = createContext2D()
/** 渲染插值图像区域 */
function renderMattingInterpolation(interpolationConfig: RenderInterpolationConfig) {
const { drawingConfig, maxMovement } = interpolationConfig
const { step, stepBase, drawingCtx, radius, hardness } = drawingConfig
let { x, y } = drawingConfig
const { stepX, stepY } = computeInterpolationStep(interpolationConfig)
resetInterpolationCtx(drawingCtx)
for (let movement = 0, moved = movement; movement < maxMovement; movement += stepBase, x += stepX, y += stepY) {
if (needDrawInterpolationPoint(movement, moved, step)) {
moved = movement
drawBrushPoint({ ctx: interpolationCtx, x: fixed(x), y: fixed(y), radius, hardness })
}
}
drawMattingInterpolationTrack(drawingConfig)
}
/** 绘制插值轨迹 */
function drawMattingInterpolationTrack(drawingConfig: MattingDrawingConfig) {
const { isErasing, hiddenCtx, drawingCtx } = drawingConfig
if (isErasing) {
hiddenCtx.value.drawImage(interpolationCtx.canvas, 0, 0)
} else {
drawMattingTrack(drawingConfig, () => {
drawingCtx.drawImage(interpolationCtx.canvas, 0, 0)
})
}
}
/** 重置用于插值绘制的画板 */
function resetInterpolationCtx(drawingCtx: CanvasRenderingContext2D) {
const { width, height } = drawingCtx.canvas
interpolationCtx.canvas.width = width
interpolationCtx.canvas.height = height
interpolationCtx.clearRect(0, 0, width, height)
}
/** 绘制擦补/抠图区域的圆点 */
function drawMattingPoint(drawingConfig: MattingDrawingConfig) {
const { isErasing, hiddenCtx, drawingCtx } = drawingConfig
const { x, y, radius, hardness } = drawingConfig
if (isErasing) {
drawBrushPoint({ ctx: hiddenCtx.value, x, y, radius, hardness })
} else {
drawMattingTrack(drawingConfig, () => {
drawBrushPoint({ ctx: drawingCtx, x, y, radius, hardness })
})
}
}
/** TODO: 绘制修补/扣除的轨迹 */
async function drawMattingTrack(drawingConfig: MattingDrawingConfig, drawingCallback: VoidFunction) {
const { hiddenCtx, drawingCtx, mattingSource } = drawingConfig
drawingCtx.globalCompositeOperation = GLOBAL_COMPOSITE_OPERATION_SOURCE_OVER
drawingCtx.drawImage(mattingSource, 0, 0)
drawingCtx.globalCompositeOperation = GLOBAL_COMPOSITE_OPERATION_DESTINATION_IN
drawingCallback()
hiddenCtx.value.drawImage(drawingCtx.canvas, 0, 0)
}
/** 在呈现的画布上绘制图像 */
function drawResultArea(drawingConfig: MattingDrawingConfig) {
const { ctx, hiddenCtx, positionRange, scaleRatio, isErasing } = drawingConfig
transformedDrawImage({
ctx: ctx.value as CanvasRenderingContext2D,
hiddenCtx: hiddenCtx.value,
positionRange,
scaleRatio,
withBorder: isInResultBoard(isErasing),
})
}
/** 绘制图像的画板是否为输出画板 */
export function isInResultBoard(isErasing: boolean | undefined) {
return isErasing !== undefined
}

View File

@ -0,0 +1,103 @@
/*
* @Author: ShawnPhang
* @Date: 2023-10-05 16:33:07
* @Description:
* @LastEditors: ShawnPhang <https://m.palxp.cn>
* @LastEditTime: 2023-10-08 11:09:59
*/
import { HIDDEN_BOARD_GAP_SIZE, HIDDEN_BOARD_MAX_SIZE, INITIAL_SCALE_RATIO } from '../constants'
import { BoardRect, GapSize, RectSize } from '../types/common'
import { TransformParameters, TransformParametersConfig } from '../types/init-matting'
import { computeScaledImageSize, fixed } from './util'
/** 计算画板的左上角坐标及宽高 */
export function computeBoardRect(canvas: HTMLCanvasElement): BoardRect {
const inputBoardRect: DOMRect = canvas.getBoundingClientRect()
const domRect: DOMRect = document.documentElement.getBoundingClientRect()
return computeBoardRectSize(inputBoardRect, domRect)
}
export function computeBoardRectSize(inputBoardRect: DOMRect, domRect: DOMRect) {
const { width, height, left: boardLeft, top: boardTop } = inputBoardRect
const { left: domLeft, top: domTop } = domRect
const left = boardLeft - domLeft
const top = boardTop - domTop
return { left, top, width, height }
}
/** 计合法的图片尺寸(低于2k分辨率的尺寸) */
export function computeValidImageSize(imageSource: ImageBitmap): RectSize {
let { width, height } = imageSource
const imageScaleRatio = computeScaleRatio({
imageSize: { width, height },
gapSize: HIDDEN_BOARD_GAP_SIZE,
targetSize: HIDDEN_BOARD_MAX_SIZE,
})
width *= imageScaleRatio
height *= imageScaleRatio
return { width, height }
}
/** 计算自适应缩放比例 */
export function computeScaleRatio(transformParametersConfig: TransformParametersConfig): number {
const { imageSize, gapSize, targetSize } = transformParametersConfig
const drawingAreaSize = getDrawingAreaSize(targetSize, gapSize)
return Math.min(Math.min(drawingAreaSize.width / imageSize.width, drawingAreaSize.height / imageSize.height), INITIAL_SCALE_RATIO)
}
/** 默认最大绘制区的尺寸(即画框尺寸减去间隙) */
export function getDrawingAreaSize(boardSize: RectSize, gapSize: GapSize): RectSize {
return {
width: boardSize.width - gapSize.horizontal * 2,
height: boardSize.height - gapSize.vertical * 2,
}
}
/** 计算自适应变换(缩放、平移)参数 */
export function computeTransformParameters(transformParametersConfig: TransformParametersConfig): TransformParameters {
const scaleRatio = computeScaleRatio(transformParametersConfig)
const positionRange = computePositionRange(transformParametersConfig, scaleRatio)
return { scaleRatio, positionRange }
}
/** 计算自适应变换后的绘制区域 */
function computePositionRange(transformParametersConfig: TransformParametersConfig, scaleRatio: number) {
const scaledImageSize = computeScaledImageSize(transformParametersConfig.imageSize, scaleRatio)
return {
minX: getPositionRangeMinX(transformParametersConfig, scaledImageSize),
maxX: getPositionRangeMaxX(transformParametersConfig, scaledImageSize),
minY: getPositionRangeMinY(transformParametersConfig, scaledImageSize),
maxY: getPositionRangeMaxY(transformParametersConfig, scaledImageSize),
}
}
/** 计算绘制区域范围最小x坐标(相对于画布左上角) */
function getPositionRangeMinX(transformParametersConfig: TransformParametersConfig, scaledImageSize: RectSize) {
const { gapSize, targetSize } = transformParametersConfig
return fixed((getDrawingAreaSize(targetSize, gapSize).width - scaledImageSize.width) / 2) + gapSize.horizontal
}
function getPositionRangeMinY(transformParametersConfig: TransformParametersConfig, scaledImageSize: RectSize) {
const { gapSize, targetSize } = transformParametersConfig
return fixed((getDrawingAreaSize(targetSize, gapSize).height - scaledImageSize.height) / 2) + gapSize.vertical
}
function getPositionRangeMaxX(transformParametersConfig: TransformParametersConfig, scaledImageSize: RectSize) {
return fixed(getPositionRangeMinX(transformParametersConfig, scaledImageSize) + scaledImageSize.width)
}
function getPositionRangeMaxY(transformParametersConfig: TransformParametersConfig, scaledImageSize: RectSize) {
return fixed(getPositionRangeMinY(transformParametersConfig, scaledImageSize) + scaledImageSize.height)
}
export const computeHelpers = {
computeBoardRect,
computeTransformParameters,
computeScaleRatio,
computeValidImageSize,
computePositionRange,
getPositionRangeMinX,
getPositionRangeMaxX,
getPositionRangeMinY,
getPositionRangeMaxY,
}

View File

@ -0,0 +1,205 @@
import { EventType, GLOBAL_COMPOSITE_OPERATION_DESTINATION_OUT, GLOBAL_COMPOSITE_OPERATION_SOURCE_OVER } from '../constants'
import { MouseMovements, PixelPosition } from '../types/common'
import { BoardDrawingConfig, BrushDrawingBaseConfig, CanDrawAndBindMouseListenerConfig, ComputePositionAndMovementConfig, ComputeRealPositionConfig, DrawingListenerConfig, InitDrawingConfig, InitDrawingListenerConfig, PositionAndMovements } from '../types/drawing-listeners'
import { isInImageRange } from './drawing-compute'
import { executeMattingDrawing } from './drawing-helper'
import { transformHelpers, updateRangeByMovements } from './transform-helper'
import { computeRealRadius, computeStep, computeStepBase, getRawDistance } from './util'
export function initDrawingListeners(config: InitDrawingListenerConfig) {
const { listenerManager, initDrawingConfig } = config
const listenerConfig = generateDrawingListenerConfig(config)
let spaceDown = false
const {
inputBoardDrawingConfig: { ctx: inputCtx, hiddenCtx: inputHiddenCtx },
outputBoardDrawingConfig: { hiddenCtx: outputHiddenCtx },
brushDrawingBaseConfig: { positionRange },
} = listenerConfig
const { boardRect, draggingInputBoard } = listenerConfig
resetPivotalOptions(listenerConfig)
const drawingListener = generateDrawingListener(listenerConfig)
let canDrawAndBindListener = false
// --- TODO: 临时快捷键测试 ----
document.removeEventListener('keydown', handleKeydown, false)
function handleKeydown(e: any) {
if (e.code === 'Space') {
e.preventDefault()
}
spaceDown = e.code === 'Space'
}
document.addEventListener('keydown', handleKeydown, false)
function handleKeyup(e: any) {
spaceDown = false
}
document.removeEventListener('keydown', handleKeyup, false)
document.addEventListener('keyup', handleKeyup, false)
// --- END ---
listenerManager.initMouseListeners({
mouseTarget: (inputCtx.value as CanvasRenderingContext2D).canvas,
down(ev) {
if (!spaceDown) {
canDrawAndBindListener = canDrawAndBindMoveListener({
ev,
boardRect,
positionRange,
draggingInputBoard,
})
if (canDrawAndBindListener) {
drawingListener(ev)
}
return canDrawAndBindListener
}
},
move(ev) {
const { positionRange } = initDrawingConfig.transformConfig
spaceDown && updateRangeByMovements(ev, positionRange)
if (!draggingInputBoard && canDrawAndBindListener) {
drawingListener(ev)
}
},
up(ev) {
if (!draggingInputBoard && canDrawAndBindListener) {
canDrawAndBindListener = false
drawingListener(ev)
}
},
})
}
/** 生成绘制监听器的配置对象 */
function generateDrawingListenerConfig(config: InitDrawingListenerConfig): DrawingListenerConfig {
const {
imageSources,
imageSources: { raw, mask },
initDrawingConfig,
boardContexts,
...restConfig
} = config
const { inputCtx, inputHiddenCtx, inputDrawingCtx, outputCtx, outputHiddenCtx, outputDrawingCtx } = boardContexts
const brushDrawingBaseConfig: BrushDrawingBaseConfig = generateBrushBaseConfig(initDrawingConfig)
const inputBoardDrawingConfig: BoardDrawingConfig = {
ctx: inputCtx,
hiddenCtx: inputHiddenCtx,
drawingCtx: inputDrawingCtx,
mattingSource: mask,
}
const outputBoardDrawingConfig: BoardDrawingConfig = {
ctx: outputCtx,
hiddenCtx: outputHiddenCtx,
drawingCtx: outputDrawingCtx,
mattingSource: raw,
}
return {
brushDrawingBaseConfig,
mattingSources: imageSources,
inputBoardDrawingConfig,
outputBoardDrawingConfig,
...restConfig,
}
}
/** 重置画板配置对象中关键的选项 */
function resetPivotalOptions(config: DrawingListenerConfig) {
const { inputBoardDrawingConfig, outputBoardDrawingConfig } = config
const {
mattingSources: { raw, mask },
isErasing,
} = config
if (isErasing) {
inputBoardDrawingConfig.mattingSource = raw
outputBoardDrawingConfig.hiddenCtx.value.globalCompositeOperation = GLOBAL_COMPOSITE_OPERATION_DESTINATION_OUT
} else {
inputBoardDrawingConfig.mattingSource = mask
outputBoardDrawingConfig.hiddenCtx.value.globalCompositeOperation = GLOBAL_COMPOSITE_OPERATION_SOURCE_OVER
}
}
/** 生成画笔的基础配置对象 */
function generateBrushBaseConfig(config: InitDrawingConfig): BrushDrawingBaseConfig {
const {
radius: rawRadius,
hardness,
transformConfig: { scaleRatio, positionRange },
} = config
const radius = computeRealRadius(rawRadius.value, scaleRatio)
const stepBase = computeStepBase(radius)
const step = computeStep(radius)
return { radius, step, stepBase, scaleRatio, positionRange, hardness: hardness.value }
}
/** 生成擦补画笔的绘制监听器 */
function generateDrawingListener(config: DrawingListenerConfig) {
const {
brushDrawingBaseConfig,
brushDrawingBaseConfig: { step, scaleRatio, positionRange },
boardRect: { left, top },
} = config
const { inputBoardDrawingConfig, outputBoardDrawingConfig, isErasing } = config
let totalMovement = 0
return (ev: MouseEvent) => {
// TODO: 绘制
const positionAndMovements = computePositionAndMovements({
ev,
scaleRatio,
positionRange,
left,
top,
})
const { movementX, movementY } = positionAndMovements
const commonPointConfig = {
...brushDrawingBaseConfig,
...positionAndMovements,
}
totalMovement += getRawDistance(movementX, movementY)
if (canDrawing(totalMovement, step, ev.type)) {
totalMovement = 0
executeMattingDrawing([
{ ...commonPointConfig, ...inputBoardDrawingConfig },
{ ...commonPointConfig, ...outputBoardDrawingConfig, isErasing },
])
}
}
}
/** 判断是否可以绘制 */
function canDrawing(totalMovement: number, step: number, eventType: string): boolean {
return totalMovement >= step || eventType === EventType.MouseDown
}
/** 计算绘制点坐标位置及鼠标指针水平、垂直移动距离 */
function computePositionAndMovements(config: ComputePositionAndMovementConfig): PositionAndMovements {
const { ev, scaleRatio, positionRange, left, top } = config
const { minX, minY } = positionRange
const { movementX, movementY, pageX, pageY } = ev
const realPosition = computeRealPosition({ pageX, pageY, left, top, minX, minY, scaleRatio })
const realMovements = computeRealMovements({ movementX, movementY }, scaleRatio)
return { ...realPosition, ...realMovements }
}
/** 计算相对于真实图像尺寸的鼠标位置 */
function computeRealPosition(config: ComputeRealPositionConfig): PixelPosition {
const { pageX, pageY, left, top, minX, minY, scaleRatio } = config
const x = (pageX - left - minX) / scaleRatio
const y = (pageY - top - minY) / scaleRatio
return { x, y }
}
/** 计算相对于真实图像尺寸的移动距离 */
function computeRealMovements(rawMovements: MouseMovements, scaleRatio: number) {
const { movementX: rawMovementX, movementY: rawMovementY } = rawMovements
const movementX = rawMovementX / scaleRatio
const movementY = rawMovementY / scaleRatio
return { movementX, movementY }
}
/** 判断是否可以绘制且绑定鼠标移动绘制的监听器 */
function canDrawAndBindMoveListener(canDrawAndBindConfig: CanDrawAndBindMouseListenerConfig) {
const { ev, boardRect, positionRange, draggingInputBoard } = canDrawAndBindConfig
const { pageX, pageY } = ev
const { left, top } = boardRect
const { minX, maxX, minY, maxY } = positionRange
const x = transformHelpers.computePivot(pageX, left)
const y = transformHelpers.computePivot(pageY, top)
const inImageRange = isInImageRange({ x, y, minX, maxX, minY, maxY })
return inImageRange && !draggingInputBoard
}

View File

@ -0,0 +1,94 @@
/*
* @Author: ShawnPhang
* @Date: 2023-10-05 16:33:07
* @Description:
* @LastEditors: ShawnPhang <https://m.palxp.cn>
* @LastEditTime: 2023-10-08 11:10:05
*/
import { DEFAULT_IMAGE_SMOOTH_CHOICE, INITIAL_GAP_SIZE } from '../constants'
import { TransformConfig } from '../types/common'
import { ComputeTransformConfigConfig, GenerateMaskSourceConfig, GetValidTransformParametersConfig, InitMattingConfig, InitMattingResult } from '../types/init-matting'
import { getLoadedImage, initHiddenBoard, initHiddenBoardWithSource, transformedDrawImage } from './dom-helper'
import { computeTransformParameters, computeValidImageSize } from './init-compute'
import { initMaskRenderer } from './mask-renderer'
export async function initMatting(initMattingConfig: InitMattingConfig): Promise<InitMattingResult> {
const {
boardContexts: { inputCtx, outputCtx, inputHiddenCtx, inputDrawingCtx, outputHiddenCtx, outputDrawingCtx },
picFile,
transformConfig,
targetSize,
gapSize,
} = initMattingConfig
// hideCanvas(inputContext2D, outputContext2D);
;(inputCtx.value as CanvasRenderingContext2D).imageSmoothingEnabled = DEFAULT_IMAGE_SMOOTH_CHOICE
;(outputCtx.value as CanvasRenderingContext2D).imageSmoothingEnabled = DEFAULT_IMAGE_SMOOTH_CHOICE
const imageSource = await getLoadedImage(picFile)
const { scaleRatio, positionRange } = getValidTransformConfig({ imageSource, transformConfig, targetSize, gapSize })
const validImageSize = computeValidImageSize(imageSource)
initHiddenBoardWithSource({
imageSource,
targetSize: validImageSize,
hiddenCtx: inputHiddenCtx.value,
drawingCtx: inputDrawingCtx,
})
transformedDrawImage({
hiddenCtx: inputHiddenCtx.value,
ctx: inputCtx.value as CanvasRenderingContext2D,
scaleRatio,
positionRange,
})
initHiddenBoard({
targetSize: validImageSize,
hiddenCtx: outputHiddenCtx.value,
drawingCtx: outputDrawingCtx,
})
const raw = await createImageBitmap(inputHiddenCtx.value.canvas)
const mask = await generateMaskImageSource({ targetSize: validImageSize, imageSource })
return { orig: imageSource, raw, mask, positionRange, scaleRatio }
}
/** 生成蒙版后的图像资源 */
function generateMaskImageSource(config: GenerateMaskSourceConfig): Promise<ImageBitmap> {
const {
targetSize: { width, height },
imageSource,
} = config
const cvs = document.createElement('canvas')
cvs.width = width
cvs.height = height
const render = initMaskRenderer(cvs)
if (render) {
render(imageSource)
}
return createImageBitmap(cvs)
}
/** 获取有效的变换配置 */
function getValidTransformConfig(getParametersConfig: GetValidTransformParametersConfig): TransformConfig {
const { transformConfig, ...computeConfig } = getParametersConfig
if (isInvalidTransformConfig(transformConfig)) {
const { scaleRatio, positionRange } = computeTransformConfig(computeConfig)
transformConfig.scaleRatio = scaleRatio
transformConfig.positionRange = positionRange
}
return transformConfig as TransformConfig
}
/** 判断变换配置是否无效 */
function isInvalidTransformConfig(transformConfig: Partial<TransformConfig>) {
const { scaleRatio, positionRange } = transformConfig
return !scaleRatio || !positionRange
}
/** 计算画板的变换配置对象 */
function computeTransformConfig(computeConfig: ComputeTransformConfigConfig): TransformConfig {
const { imageSource, targetSize, gapSize = INITIAL_GAP_SIZE } = computeConfig
const imageSize = computeValidImageSize(imageSource)
return computeTransformParameters({
gapSize,
imageSize,
targetSize,
})
}

View File

@ -0,0 +1,42 @@
/*
* @Author: ShawnPhang
* @Date: 2023-10-05 16:33:07
* @Description:
* @LastEditors: ShawnPhang <https://m.palxp.cn>
* @LastEditTime: 2023-10-08 11:10:07
*/
import { InitMattingDragConfig, InitMattingScaleConfig } from '../types/transform'
import { redrawMattingBoardsWhileScaling, updateRangeByMovements } from './transform-helper'
/** 初始化画板变换的监听器 */
export function initDragListener(mattingTransformConfig: InitMattingDragConfig) {
const {
outputContexts: { ctx: outputCtx2D },
// inputContexts: { ctx: inputCtx2D },
transformConfig,
listenerManager,
} = mattingTransformConfig
listenerManager.initMouseListeners({
mouseTarget: outputCtx2D.canvas,
move(ev) {
const { positionRange } = transformConfig
updateRangeByMovements(ev, positionRange)
},
})
}
/** 初始化缩放监听器 */
export function initScaleListener(mattingTransformConfig: InitMattingScaleConfig): VoidFunction {
const {
inputContexts: { ctx: inputCtx },
outputContexts: { ctx: outputCtx },
listenerManager,
} = mattingTransformConfig
return listenerManager.initWheelListener({
mattingBoards: [inputCtx.canvas, outputCtx.canvas],
wheel(ev) {
ev.preventDefault()
redrawMattingBoardsWhileScaling(ev, mattingTransformConfig)
},
})
}

View File

@ -0,0 +1,152 @@
import { EventType } from '../constants'
import { UnbindDownUpCache, UnbindMoveCache, MouseListenerContext, UnbindDownUpConfig, ListenerConfig, UnbindWheelCache, WheelListenerContext } from '../types/listener-manager'
const { MouseDown, Mouseup, Mousemove } = EventType
export default class ListenerManager {
private unbindDownUpCache: UnbindDownUpCache = new WeakMap()
private unbindMoveCache: UnbindMoveCache = new WeakMap()
private unbindWheelCache: UnbindWheelCache = new Set()
/** 初始化鼠标相关事件监听器 */
initMouseListeners(ctx: MouseListenerContext) {
const { mouseTarget } = ctx
this.removeMouseListeners(mouseTarget)
const unbindConfig = this.bindMouseListeners(ctx)
this.unbindDownUpCache.set(mouseTarget, unbindConfig)
}
removeMouseListeners(mouseTarget: HTMLElement) {
const unbindConfig = this.unbindDownUpCache.get(mouseTarget)
if (unbindConfig) {
const { unbindDown, unbindUp } = unbindConfig
unbindDown()
unbindUp()
}
}
private bindMouseListeners(listenersContext: MouseListenerContext): UnbindDownUpConfig {
const { mouseTarget, down, move, up } = listenersContext
const moveListener = (ev: Event) => {
requestAnimationFrame(() => move(ev as MouseEvent))
}
const downListener = (ev: Event) => {
const isTarget = ev.target === mouseTarget
const extraCondition = down && down(ev as MouseEvent)
const shouldBindMove = extraCondition !== false
if (isTarget && shouldBindMove) {
const removeMove = this.listenEvent({
eventType: Mousemove,
listener: moveListener,
stop: true,
prevent: true,
})
this.unbindMoveCache.set(mouseTarget, removeMove)
}
}
const upListener = (ev: Event) => {
up && up(ev as MouseEvent)
this.unbindMoveListeners(mouseTarget)
}
const unbindDown = this.listenEvent({
eventType: MouseDown,
listener: downListener,
})
const unbindUp = this.listenEvent({
eventType: Mouseup,
listener: upListener,
})
return { unbindDown, unbindUp }
}
/** 移除mousemove监听器 */
private unbindMoveListeners(mouseTarget: HTMLElement) {
const unbindMove = this.unbindMoveCache.get(mouseTarget)
unbindMove && unbindMove()
this.unbindMoveCache.delete(mouseTarget)
}
/** 初始化wheel事件监听器 */
initWheelListener(listenersConfig: WheelListenerContext): VoidFunction {
this.removeWheelListeners()
const removeWheel = this.bindWheelListener(listenersConfig)
this.unbindWheelCache.add(removeWheel)
return removeWheel
}
/** 解绑wheel事件监听器 */
removeWheelListeners() {
this.unbindWheelCache.forEach((unbind) => unbind())
this.unbindWheelCache.clear()
}
/** 绑定wheel事件监听器 */
private bindWheelListener(listenersConfig: WheelListenerContext) {
const { mattingBoards, wheel } = listenersConfig
return this.listenEvent(
{
eventType: EventType.Wheel,
listener: (ev) => {
if (this.canWheel(ev, mattingBoards)) {
wheel(ev as WheelEvent)
}
},
},
false,
...mattingBoards,
)
}
/** 是否可以滚动 */
private canWheel(ev: Event, mattingBoards: HTMLCanvasElement[]): boolean {
return mattingBoards.some((board) => ev.target === board)
}
/** 监听事件,返回移除监听器的回调 */
private listenEvent(listenerConfig: ListenerConfig, options: boolean | AddEventListenerOptions = false, ...targets: HTMLElement[]): VoidFunction {
const { eventType } = listenerConfig
const wrapListener = this.genWrapListener(listenerConfig)
let removeListenerCallback: VoidFunction
if (!this.isNeedToBindToTargets(targets)) {
removeListenerCallback = this.bindListener(window, eventType, wrapListener, options)
} else {
removeListenerCallback = this.bindListeners(targets, eventType, wrapListener, options)
}
return removeListenerCallback
}
private genWrapListener(listenerConfig: ListenerConfig) {
const { listener, stop, prevent } = listenerConfig
return (ev: Event) => {
if (stop) {
ev.stopPropagation()
}
if (prevent) {
ev.preventDefault()
}
listener(ev)
}
}
/** 是否需要绑定在目标元素上 */
private isNeedToBindToTargets(targets: HTMLElement[]) {
return targets.length !== 0
}
/** 为单个目标绑定监听器 */
private bindListener(target: Window | HTMLElement, eventType: string, listener: EventListener, options: boolean | AddEventListenerOptions): VoidFunction {
target.addEventListener(eventType, listener, options)
return () => target.removeEventListener(eventType, listener, options)
}
/** 为多个目标绑定监听器 */
private bindListeners(targets: HTMLElement[], eventType: string, listener: EventListener, options: boolean | AddEventListenerOptions): VoidFunction {
targets.forEach((target) => {
target.addEventListener(eventType, listener, options)
})
return () =>
targets.forEach((target) => {
target.removeEventListener(eventType, listener, options)
})
}
}

View File

@ -0,0 +1,109 @@
import { DEFAULT_MASK_COLOR } from '../constants'
import { getWebGLContext, initShaders } from '../libs/cuon-utils'
import { GLColor } from '../types/matting-drawing'
const VSHADER_SOURCE = `
attribute vec4 a_Position;
attribute vec2 a_TexCoord;
varying vec2 v_TexCoord;
void main() {
gl_Position = a_Position;
v_TexCoord = a_TexCoord;
}
`
const FSHADER_SOURCE = `
precision highp float;
uniform sampler2D u_Sampler;
uniform vec4 u_MaskColor;
varying vec2 v_TexCoord;
void main() {
vec4 color = texture2D(u_Sampler, v_TexCoord);
vec3 mixRGB = color.a > 0.0 ? mix(color.rgb, u_MaskColor.rgb, u_MaskColor.a) : color.rgb;
gl_FragColor = vec4(mixRGB, color.a);
}
`
export function initMaskRenderer(cvs: HTMLCanvasElement, maskColor: GLColor = DEFAULT_MASK_COLOR) {
const gl = getWebGLContext(cvs)
if (!gl) {
return console.error('获取WebGL绘制上下文失败!')
}
if (!initShaders(gl, VSHADER_SOURCE, FSHADER_SOURCE)) {
return console.error('着色器初始化失败!')
}
if (!initBuffers(gl)) {
return console.error('缓冲区初始化失败!')
}
initUniform(gl, 'u_MaskColor', maskColor)
return (image: TexImageSource) => {
console.time('draw mask image')
if (!initTexture(gl, image)) {
return console.error('纹理初始化失败!')
}
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
console.timeEnd('draw mask image')
}
}
function initUniform(gl: WebGLRenderingContext, location: string, val: number | GLColor) {
const u_Location = gl.getUniformLocation(gl.program, location)
if (!u_Location) {
return console.error('获取attribute变量存储位置失败!')
}
if (Array.isArray(val)) {
gl.uniform4fv(u_Location, val)
} else {
gl.uniform1i(u_Location, val)
}
}
function initBuffers(gl: WebGLRenderingContext) {
const aPosition = gl.getAttribLocation(gl.program, 'a_Position')
const aTexCoord = gl.getAttribLocation(gl.program, 'a_TexCoord')
if (!~aPosition || !~aTexCoord) {
console.error('获取attribute变量存储位置失败!')
return false
}
const verticesBuffer = gl.createBuffer()
const coordsBuffer = gl.createBuffer()
if (!verticesBuffer || !coordsBuffer) {
console.error('创建缓冲区对象失败!')
return false
}
bindArrayBuffer(gl, verticesBuffer, aPosition, new Float32Array([-1, 1, 1, 1, -1, -1, 1, -1]))
bindArrayBuffer(gl, coordsBuffer, aTexCoord, new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]))
return true
}
function bindArrayBuffer(gl: WebGLRenderingContext, buffer: WebGLBuffer, attrib: number, data: Float32Array, pointNum = 2) {
gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW)
gl.vertexAttribPointer(attrib, pointNum, gl.FLOAT, false, 0, 0)
gl.enableVertexAttribArray(attrib)
}
function initTexture(gl: WebGLRenderingContext, image: TexImageSource) {
const texture = gl.createTexture()
if (!texture) {
console.error('创建纹理对象失败!')
return false
}
const u_Sampler = gl.getUniformLocation(gl.program, 'u_Sampler')
if (!u_Sampler) {
console.error('获取取样器变量存储位置失败!')
return false
}
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1)
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, texture)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image)
gl.uniform1i(u_Sampler, 0)
return true
}

View File

@ -0,0 +1,103 @@
import { ZOOM_OUT_COEFFICIENT, ZOOM_IN_COEFFICIENT, SCALE_STEP, MIN_SCALE_RATIO, MAX_SCALE_RATIO } from '../constants'
import { PixelPosition, PositionRange, TransformConfig } from '../types/common'
import { GenerateRangeOffsetConfig, InitMattingTransformConfig } from '../types/transform'
import { computeHelpers } from './init-compute'
const { sign } = Math
/** 生成表示绘制范围各个值偏移量的对象 */
export function generateRangeOffset(rangeOffsetConfig: GenerateRangeOffsetConfig): PositionRange {
const { pageX, pageY, positionRange } = rangeOffsetConfig
const { minX, maxX, minY, maxY } = positionRange
return { minX: minX - pageX, maxX: maxX - pageX, minY: minY - pageY, maxY: maxY - pageY }
}
/** 根据当前鼠标位置更新绘制范围 */
export function updateRangeByMovements(ev: MouseEvent, positionRange: PositionRange) {
const { movementX: deltaX, movementY: deltaY } = ev
positionRange.minX += deltaX
positionRange.maxX += deltaX
positionRange.minY += deltaY
positionRange.maxY += deltaY
}
/** 变换(平移、缩放)时重绘画板中图像 */
export function redrawMattingBoardsWhileScaling(ev: WheelEvent, scalingConfig: InitMattingTransformConfig) {
const { transformConfig, inputContexts: inputContext, outputContexts: outputContext } = scalingConfig
updateTransformConfigWhileScaling(ev, transformConfig)
}
/** 鼠标滚轮滚动缩放时更新变换参数 */
function updateTransformConfigWhileScaling(ev: WheelEvent, transformConfig: TransformConfig) {
const { deltaY, pageX, pageY, target } = ev
const { positionRange, scaleRatio } = transformConfig
const { left, top } = computeHelpers.computeBoardRect(target as HTMLCanvasElement)
const x = transformHelpers.computePivot(pageX, left)
const y = transformHelpers.computePivot(pageY, top)
const deltaRatio = transformHelpers.computeDeltaRatio(deltaY)
const targetScaleRatio = transformHelpers.computeNewScaleRatio(scaleRatio, deltaRatio)
transformConfig.scaleRatio = transformHelpers.computeClampedTargetScaleRatio(targetScaleRatio)
// 不能直接使用deltaRatioscaleRatio接近最大/最小值时,二者就不相等了。
const rangeScaleRatio = computeRangeScaleRatio(transformConfig.scaleRatio, scaleRatio)
transformConfig.positionRange = transformHelpers.computeNewPositionRange(positionRange, { x, y }, rangeScaleRatio)
}
/** 计算鼠标的位置对应的像素在图像中的位置 */
function computePivot(pagePivot: number, leftOrTop: number) {
return pagePivot - leftOrTop
}
/** 计算变化比率 */
function computeDeltaRatio(deltaY: number) {
const scaleCoefficient = transformHelpers.isZoomOut(deltaY) ? ZOOM_OUT_COEFFICIENT : ZOOM_IN_COEFFICIENT
return scaleCoefficient * SCALE_STEP
}
/** 是否为缩小 */
function isZoomOut(deltaY: number): boolean {
return -sign(deltaY) === ZOOM_OUT_COEFFICIENT
}
/** 计算新的缩放比率 */
function computeNewScaleRatio(scaleRatio: number, deltaRatio: number): number {
return scaleRatio + scaleRatio * deltaRatio
}
/** 计算绘制范围的变化比率 */
function computeRangeScaleRatio(newRatio: number, oldRatio: number): number {
return (newRatio - oldRatio) / oldRatio
}
/** 夹住缩放比例使其不会超出范围 */
function computeClampedTargetScaleRatio(scaleRatio: number): number {
return scaleRatio < MIN_SCALE_RATIO ? MIN_SCALE_RATIO : scaleRatio > MAX_SCALE_RATIO ? MAX_SCALE_RATIO : scaleRatio
}
/** 计算新的绘制范围 */
function computeNewPositionRange(positionRange: PositionRange, position: PixelPosition, deltaRatio: number): PositionRange {
const { x, y } = position
let { minX, maxX, minY, maxY } = positionRange
minX = transformHelpers.computeNewSingleRange(minX, x, deltaRatio)
maxX = transformHelpers.computeNewSingleRange(maxX, x, deltaRatio)
minY = transformHelpers.computeNewSingleRange(minY, y, deltaRatio)
maxY = transformHelpers.computeNewSingleRange(maxY, y, deltaRatio)
return { minX, maxX, minY, maxY }
}
/** 计算缩放后x/y轴方向的新的绘制范围值 */
function computeNewSingleRange(singleRange: number, pivot: number, deltaRatio: number): number {
const vectorDistance = singleRange - pivot
const deltaRange = vectorDistance * deltaRatio
return singleRange + deltaRange
}
export const transformHelpers = {
updateTransformConfigWhileScaling,
computePivot,
computeDeltaRatio,
isZoomOut,
computeNewScaleRatio,
computeClampedTargetScaleRatio,
computeNewPositionRange,
computeNewSingleRange,
}

View File

@ -0,0 +1,51 @@
/*
* @Author: ShawnPhang
* @Date: 2023-10-05 16:33:07
* @Description:
* @LastEditors: ShawnPhang <https://m.palxp.cn>
* @LastEditTime: 2023-10-08 11:10:19
*/
import { DRAWING_STEP_BASE, DRAWING_STEP_BASE_BASE, MIN_RADIUS } from '../constants'
import { RectSize, TransformConfig } from '../types/common'
const { sqrt, max } = Math
export function fixed(num: number): number {
return num | 0
}
// 比Math.hypot(x,y)快一些(在数量级较大的情况下)
export function getRawDistance(xDistance: number, yDistance: number): number {
return sqrt(xDistance ** 2 + yDistance ** 2)
}
/** 计算插值绘制的间隔步长 */
export function computeStepBase(radius: number) {
return radius / DRAWING_STEP_BASE_BASE
}
/** 计算真实(相对真实如果图像分辨率会控制在2K以内以保证性能)尺寸的画笔绘制点的半径 */
export function computeRealRadius(rawRadius: number, scaleRatio: number) {
return max(MIN_RADIUS, rawRadius) / scaleRatio
}
/** 计算移动绘制的节流步长 */
export function computeStep(radius: number) {
return radius / DRAWING_STEP_BASE
}
/** 基于新的缩放比例计算新的绘制范围 */
export function computeNewTransformConfigByScaleRatio(transformConfig: TransformConfig, pictureSize: RectSize, scaleRatio: number): TransformConfig {
const { minX, minY } = transformConfig.positionRange
const { width, height } = pictureSize
const maxX = minX + width * scaleRatio
const maxY = minY + height * scaleRatio
return { positionRange: { minX, maxX, minY, maxY }, scaleRatio }
}
/** 获取图片缩放到画框区域内的实际尺寸 */
export function computeScaledImageSize(imageSize: RectSize, scaleRatio: number): RectSize {
return {
width: imageSize.width * scaleRatio,
height: imageSize.height * scaleRatio,
}
}

View File

@ -0,0 +1,41 @@
import { App } from 'vue'
import matting from './ImageExtraction.vue'
matting.install = (app: App): void => {
app.component(matting.name, matting)
}
export default matting
export interface MattingType {
value: {}
/** 是否为擦除画笔 */
isErasing: boolean
/** 下载结果图 */
onDownloadResult: Function
/** 返回结果图 */
getResult: Function
/** input表单选择文件的回调 */
onFileChange: Function
/**
*
*/
initLoadImages: Function
/** 画笔尺寸 */
radius: number | string
/** 画笔尺寸:计算属性,显示值 */
brushSize: any
/** 画笔硬度 */
hardness: number | string
/** 画笔硬度:计算属性,显示值 */
hardnessText: any
/** 常量 */
constants: {
RADIUS_SLIDER_MIN: number
RADIUS_SLIDER_MAX: number
RADIUS_SLIDER_STEP: number
HARDNESS_SLIDER_MAX: number
HARDNESS_SLIDER_STEP: number
HARDNESS_SLIDER_MIN: number
}
}

View File

@ -0,0 +1,137 @@
// cuon-utils.js (c) 2012 kanda and matsuda
import { WebGLDebugUtils } from './webgl-debug';
import { WebGLUtils } from './webgl-utils';
/**
* Create a program object and make current
* @param gl GL context
* @param vshader a vertex shader program (string)
* @param fshader a fragment shader program (string)
* @return true, if the program object was created and successfully made current
*/
declare global {
interface WebGLRenderingContext {
program: WebGLProgram;
}
}
export function initShaders(
gl: WebGLRenderingContext,
vshader: string,
fshader: string
) {
const program = createProgram(gl, vshader, fshader);
if (!program) {
console.error('Failed to create program');
return false;
}
gl.useProgram(program);
gl.program = program;
return true;
}
/**
* Create the linked program object
* @param gl GL context
* @param vshader a vertex shader program (string)
* @param fshader a fragment shader program (string)
* @return created program object, or null if the creation has failed
*/
export function createProgram(
gl: WebGLRenderingContext,
vshader: string,
fshader: string
): WebGLProgram | null {
// Create shader object
const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vshader);
const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fshader);
if (!vertexShader || !fragmentShader) {
return null;
}
// Create a program object
const program = gl.createProgram();
if (!program) {
return null;
}
// Attach the shader objects
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
// Link the program object
gl.linkProgram(program);
// Check the result of linking
const linked = gl.getProgramParameter(program, gl.LINK_STATUS);
if (!linked) {
const error = gl.getProgramInfoLog(program);
console.error('Failed to link program: ' + error);
gl.deleteProgram(program);
gl.deleteShader(fragmentShader);
gl.deleteShader(vertexShader);
return null;
}
return program;
}
/**
* Create a shader object
* @param gl GL context
* @param type the type of the shader object to be created
* @param source shader program (string)
* @return created shader object, or null if the creation has failed.
*/
export function loadShader(
gl: WebGLRenderingContext,
type: number,
source: string
) {
// Create shader object
const shader = gl.createShader(type);
if (shader == null) {
console.error('unable to create shader');
return null;
}
// Set the shader program
gl.shaderSource(shader, source);
// Compile the shader
gl.compileShader(shader);
// Check the result of compilation
const compiled = gl.getShaderParameter(shader, gl.COMPILE_STATUS);
if (!compiled) {
const error = gl.getShaderInfoLog(shader);
console.error('Failed to compile shader: ' + error, source);
gl.deleteShader(shader);
return null;
}
return shader;
}
/**
* Initialize and get the rendering for WebGL
* @param canvas <cavnas> element
* @param opt_debug flag to initialize the context for debugging
* @return the rendering context for WebGL
*/
export function getWebGLContext(
canvas: HTMLCanvasElement,
opt_debug?: boolean
) {
// Get the rendering context for WebGL
let gl = WebGLUtils.setupWebGL(canvas);
if (!gl) return null;
// if opt_debug is explicitly false, create the context for debugging
if (arguments.length < 2 || opt_debug) {
gl = WebGLDebugUtils.makeDebugContext(gl) as WebGLRenderingContext;
}
return gl;
}

View File

@ -0,0 +1,708 @@
//Copyright (c) 2009 The Chromium Authors. All rights reserved.
//Use of this source code is governed by a BSD-style license that can be
//found in the LICENSE file.
// Various functions for helping debug WebGL apps.
type FunctionKeys<T> = {
[P in keyof T]: T[P] extends Function ? P : never;
}[keyof T];
type DebugContextProp = { [x: number]: boolean };
type WebGLContextFuncKey = FunctionKeys<WebGLRenderingContext>;
type GLValidEnumContext = {
[prop in WebGLContextFuncKey]?: DebugContextProp;
};
type GLPartialContext = {
-readonly [prop in keyof WebGLRenderingContext]?: WebGLRenderingContext[prop];
};
type WebGLResource = (
| WebGLBuffer
| WebGLFramebuffer
| WebGLProgram
| WebGLRenderbuffer
| WebGLShader
| WebGLTexture
) & { __webglDebugContextLostId__: number };
export const WebGLDebugUtils = (function () {
/**
* Wrapped logging function.
* @param {string} msg Message to log.
*/
const log = function (msg: string) {
if (window.console && window.console.log) {
window.console.log(msg);
}
};
/**
* Which arguments are enums.
* @type {!Object.<number, string>}
*/
const glValidEnumContexts: GLValidEnumContext = {
// Generic setters and getters
enable: { 0: true },
disable: { 0: true },
getParameter: { 0: true },
// Rendering
drawArrays: { 0: true },
drawElements: { 0: true, 2: true },
// Shaders
createShader: { 0: true },
getShaderParameter: { 1: true },
getProgramParameter: { 1: true },
// Vertex attributes
getVertexAttrib: { 1: true },
vertexAttribPointer: { 2: true },
// Textures
bindTexture: { 0: true },
activeTexture: { 0: true },
getTexParameter: { 0: true, 1: true },
texParameterf: { 0: true, 1: true },
texParameteri: { 0: true, 1: true, 2: true },
texImage2D: { 0: true, 2: true, 6: true, 7: true },
texSubImage2D: { 0: true, 6: true, 7: true },
copyTexImage2D: { 0: true, 2: true },
copyTexSubImage2D: { 0: true },
generateMipmap: { 0: true },
// Buffer objects
bindBuffer: { 0: true },
bufferData: { 0: true, 2: true },
bufferSubData: { 0: true },
getBufferParameter: { 0: true, 1: true },
// Renderbuffers and framebuffers
pixelStorei: { 0: true, 1: true },
readPixels: { 4: true, 5: true },
bindRenderbuffer: { 0: true },
bindFramebuffer: { 0: true },
checkFramebufferStatus: { 0: true },
framebufferRenderbuffer: { 0: true, 1: true, 2: true },
framebufferTexture2D: { 0: true, 1: true, 2: true },
getFramebufferAttachmentParameter: { 0: true, 1: true, 2: true },
getRenderbufferParameter: { 0: true, 1: true },
renderbufferStorage: { 0: true, 1: true },
// Frame buffer operations (clear, blend, depth test, stencil)
clear: { 0: true },
depthFunc: { 0: true },
blendFunc: { 0: true, 1: true },
blendFuncSeparate: { 0: true, 1: true, 2: true, 3: true },
blendEquation: { 0: true },
blendEquationSeparate: { 0: true, 1: true },
stencilFunc: { 0: true },
stencilFuncSeparate: { 0: true, 1: true },
stencilMaskSeparate: { 0: true },
stencilOp: { 0: true, 1: true, 2: true },
stencilOpSeparate: { 0: true, 1: true, 2: true, 3: true },
// Culling
cullFace: { 0: true },
frontFace: { 0: true },
};
/**
* Map of numbers to names.
* @type {Object}
*/
let glEnums: Record<string | number, any> | null = null;
/**
* Initializes this module. Safe to call more than once.
* @param {!WebGLRenderingContext} ctx A WebGL context. If
* you have more than one context it doesn't matter which one
* you pass in, it is only used to pull out constants.
*/
function init(ctx: WebGLRenderingContext) {
if (glEnums == null) {
glEnums = {};
for (const propertyName in ctx) {
if (typeof (ctx as any)[propertyName] == 'number') {
glEnums[(ctx as any)[propertyName]] = propertyName;
}
}
}
}
/**
* Checks the utils have been initialized.
*/
function checkInit() {
if (glEnums == null) {
throw 'WebGLDebugUtils.init(ctx) not called';
}
}
/**
* Returns true or false if value matches any WebGL enum
* @param {*} glEnumKey Value to check if it might be an enum.
* @return {boolean} True if value matches one of the WebGL defined enums
*/
function mightBeEnum(glEnumKey: string | number) {
checkInit();
return !!glEnums && glEnums[glEnumKey] !== undefined;
}
/**
* Gets an string version of an WebGL enum.
*
* Example:
* const str = WebGLDebugUtil.glEnumToString(ctx.getError());
*
* @param {number} glEnumKey Value to return an enum for
* @return {string} The string version of the enum.
*/
function glEnumToString(glEnumKey: string | number) {
checkInit();
const name = glEnums && glEnums[glEnumKey];
return name !== undefined ? name : '*UNKNOWN WebGL ENUM (0x' + glEnumKey.toString(16) + ')';
}
/**
* Returns the string version of a WebGL argument.
* Attempts to convert enum arguments to strings.
* @param {string} functionName the name of the WebGL function.
* @param {number} argumentIndx the index of the argument.
* @param {*} value The value of the argument.
* @return {string} The value as a string.
*/
function glFunctionArgToString(functionName: WebGLContextFuncKey, argumentIndex: number, value: string | number) {
const funcInfo = glValidEnumContexts[functionName];
if (funcInfo !== undefined) {
if (funcInfo[argumentIndex]) {
return glEnumToString(value);
}
}
return value.toString();
}
/**
* Given a WebGL context returns a wrapped context that calls
* gl.getError after every command and calls a function if the
* result is not gl.NO_ERROR.
*
* @param {!WebGLRenderingContext} ctx The webgl context to
* wrap.
* @param {!function(err, funcName, args): void} opt_onErrorFunc
* The function to call when gl.getError returns an
* error. If not specified the default function calls
* console.log with a message.
*/
function makeDebugContext(
ctx: WebGLRenderingContext,
opt_onErrorFunc?: (err: any, funcName: keyof typeof glValidEnumContexts, args: any[]) => void,
) {
init(ctx);
opt_onErrorFunc =
opt_onErrorFunc ||
function (err, functionName, args) {
// apparently we can't do args.join(",");
let argStr = '';
for (let ii = 0; ii < args.length; ++ii) {
argStr += (ii == 0 ? '' : ', ') + glFunctionArgToString(functionName, ii as never, args[ii]);
}
log('WebGL error ' + glEnumToString(err) + ' in ' + functionName + '(' + argStr + ')');
};
// Holds booleans for each GL error so after we get the error ourselves
// we can still return it to the client app.
const glErrorShadow: Record<string | number, any> = {};
// Makes a function that calls a WebGL function and then calls getError.
function makeErrorWrapper(ctx: WebGLRenderingContext, functionName: WebGLContextFuncKey) {
return function () {
const result = (ctx as any)[functionName].apply(ctx, arguments);
const err = ctx.getError();
if (err != 0) {
glErrorShadow[err] = true;
opt_onErrorFunc!(err, functionName, arguments as any);
}
return result;
};
}
// Make a an object that has a copy of every property of the WebGL context
// but wraps all functions.
const wrapper: GLPartialContext = {};
for (const propertyName in ctx) {
const funcKey = propertyName as WebGLContextFuncKey;
if (typeof ctx[funcKey] === 'function') {
wrapper[funcKey] = makeErrorWrapper(ctx, funcKey);
} else {
wrapper[funcKey] = (ctx as any)[funcKey];
}
}
// Override the getError function with one that returns our saved results.
wrapper.getError = function () {
for (const err in glErrorShadow) {
if (glErrorShadow[err]) {
glErrorShadow[err] = false;
return +err;
}
}
return ctx.NO_ERROR;
};
return wrapper;
}
function resetToInitialState(ctx: WebGLRenderingContext) {
const numAttribs = ctx.getParameter(ctx.MAX_VERTEX_ATTRIBS);
const tmp = ctx.createBuffer();
ctx.bindBuffer(ctx.ARRAY_BUFFER, tmp);
for (let ii = 0; ii < numAttribs; ++ii) {
ctx.disableVertexAttribArray(ii);
ctx.vertexAttribPointer(ii, 4, ctx.FLOAT, false, 0, 0);
ctx.vertexAttrib1f(ii, 0);
}
ctx.deleteBuffer(tmp);
const numTextureUnits = ctx.getParameter(ctx.MAX_TEXTURE_IMAGE_UNITS);
for (let ii = 0; ii < numTextureUnits; ++ii) {
ctx.activeTexture(ctx.TEXTURE0 + ii);
ctx.bindTexture(ctx.TEXTURE_CUBE_MAP, null);
ctx.bindTexture(ctx.TEXTURE_2D, null);
}
ctx.activeTexture(ctx.TEXTURE0);
ctx.useProgram(null);
ctx.bindBuffer(ctx.ARRAY_BUFFER, null);
ctx.bindBuffer(ctx.ELEMENT_ARRAY_BUFFER, null);
ctx.bindFramebuffer(ctx.FRAMEBUFFER, null);
ctx.bindRenderbuffer(ctx.RENDERBUFFER, null);
ctx.disable(ctx.BLEND);
ctx.disable(ctx.CULL_FACE);
ctx.disable(ctx.DEPTH_TEST);
ctx.disable(ctx.DITHER);
ctx.disable(ctx.SCISSOR_TEST);
ctx.blendColor(0, 0, 0, 0);
ctx.blendEquation(ctx.FUNC_ADD);
ctx.blendFunc(ctx.ONE, ctx.ZERO);
ctx.clearColor(0, 0, 0, 0);
ctx.clearDepth(1);
ctx.clearStencil(-1);
ctx.colorMask(true, true, true, true);
ctx.cullFace(ctx.BACK);
ctx.depthFunc(ctx.LESS);
ctx.depthMask(true);
ctx.depthRange(0, 1);
ctx.frontFace(ctx.CCW);
ctx.hint(ctx.GENERATE_MIPMAP_HINT, ctx.DONT_CARE);
ctx.lineWidth(1);
ctx.pixelStorei(ctx.PACK_ALIGNMENT, 4);
ctx.pixelStorei(ctx.UNPACK_ALIGNMENT, 4);
ctx.pixelStorei(ctx.UNPACK_FLIP_Y_WEBGL, false);
ctx.pixelStorei(ctx.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
// TODO: Delete this IF.
if (ctx.UNPACK_COLORSPACE_CONVERSION_WEBGL) {
ctx.pixelStorei(ctx.UNPACK_COLORSPACE_CONVERSION_WEBGL, ctx.BROWSER_DEFAULT_WEBGL);
}
ctx.polygonOffset(0, 0);
ctx.sampleCoverage(1, false);
ctx.scissor(0, 0, ctx.canvas.width, ctx.canvas.height);
ctx.stencilFunc(ctx.ALWAYS, 0, 0xffffffff);
ctx.stencilMask(0xffffffff);
ctx.stencilOp(ctx.KEEP, ctx.KEEP, ctx.KEEP);
ctx.viewport(0, 0, (ctx.canvas as HTMLCanvasElement).clientWidth, (ctx.canvas as HTMLCanvasElement).clientHeight);
ctx.clear(ctx.COLOR_BUFFER_BIT | ctx.DEPTH_BUFFER_BIT | ctx.STENCIL_BUFFER_BIT);
// TODO: This should NOT be needed but Firefox fails with 'hint'
while (ctx.getError());
}
function makeLostContextSimulatingContext(ctx: WebGLRenderingContext) {
const wrapper_: GLPartialContext & {
loseContext?: Function;
restoreContext?: Function;
registerOnContextLostListener?: Function;
registerOnContextRestoredListener?: Function;
} = {};
let contextId_ = 1;
let contextLost_ = false;
const resourceDb_: WebGLResource[] = [];
let onLost_: Function | undefined = undefined;
let onRestored_: Function | undefined = undefined;
let nextOnRestored_: Function | undefined = undefined;
// Holds booleans for each GL error so can simulate errors.
const glErrorShadow_: { [prop in number | string]: boolean } = {};
function isWebGLResource(obj: WebGLResource) {
//return false;
return (
obj instanceof WebGLBuffer ||
obj instanceof WebGLFramebuffer ||
obj instanceof WebGLProgram ||
obj instanceof WebGLRenderbuffer ||
obj instanceof WebGLShader ||
obj instanceof WebGLTexture
);
}
function checkResources(args: WebGLResource[]) {
for (let ii = 0; ii < args.length; ++ii) {
const arg = args[ii];
if (isWebGLResource(arg)) {
return arg.__webglDebugContextLostId__ == contextId_;
}
}
return true;
}
function clearErrors() {
const k = Object.keys(glErrorShadow_);
for (let ii = 0; ii < k.length; ++ii) {
delete glErrorShadow_[k[ii]];
}
}
// Makes a function that simulates WebGL when out of context.
function makeLostContextWrapper(ctx: WebGLRenderingContext, functionName: WebGLContextFuncKey) {
const f = ctx[functionName];
return function () {
// Only call the functions if the context is not lost.
if (!contextLost_) {
if (!checkResources(arguments as unknown as WebGLResource[])) {
glErrorShadow_[ctx.INVALID_OPERATION] = true;
return;
}
const result = (f as (this: WebGLRenderingContext, mask: number) => any).apply(
ctx,
arguments as unknown as [number],
);
return result;
}
};
}
for (const propertyName in ctx) {
const ctxFuncKey = propertyName as WebGLContextFuncKey;
if (typeof ctx[propertyName as keyof WebGLRenderingContext] === 'function') {
wrapper_[ctxFuncKey] = makeLostContextWrapper(ctx, ctxFuncKey);
} else {
wrapper_[ctxFuncKey] = ctx[ctxFuncKey] as any;
}
}
function makeWebGLContextEvent(statusMessage: string) {
return { statusMessage: statusMessage };
}
function freeResources() {
for (let ii = 0; ii < resourceDb_.length; ++ii) {
const resource = resourceDb_[ii];
if (resource instanceof WebGLBuffer) {
ctx.deleteBuffer(resource);
} else if (resource instanceof WebGLFramebuffer) {
ctx.deleteFramebuffer(resource);
} else if (resource instanceof WebGLProgram) {
ctx.deleteProgram(resource);
} else if (resource instanceof WebGLRenderbuffer) {
ctx.deleteRenderbuffer(resource);
} else if (resource instanceof WebGLShader) {
ctx.deleteShader(resource);
} else if (resource instanceof WebGLTexture) {
ctx.deleteTexture(resource);
}
}
}
wrapper_.loseContext = function () {
if (!contextLost_) {
contextLost_ = true;
++contextId_;
while (ctx.getError());
clearErrors();
glErrorShadow_[ctx.CONTEXT_LOST_WEBGL] = true;
setTimeout(function () {
if (onLost_) {
onLost_(makeWebGLContextEvent('context lost'));
}
}, 0);
}
};
wrapper_.restoreContext = function () {
if (contextLost_) {
if (onRestored_) {
setTimeout(function () {
freeResources();
resetToInitialState(ctx);
contextLost_ = false;
if (onRestored_) {
const callback = onRestored_;
onRestored_ = nextOnRestored_;
nextOnRestored_ = undefined;
callback(makeWebGLContextEvent('context restored'));
}
}, 0);
} else {
throw 'You can not restore the context without a listener';
}
}
};
// Wrap a few functions specially.
wrapper_.getError = function () {
if (!contextLost_) {
let err;
while ((err = ctx.getError())) {
glErrorShadow_[err] = true;
}
}
for (const err in glErrorShadow_) {
if (glErrorShadow_[err]) {
delete glErrorShadow_[err];
return +err;
}
}
return ctx.NO_ERROR;
};
const creationFunctions: WebGLContextFuncKey[] = [
'createBuffer',
'createFramebuffer',
'createProgram',
'createRenderbuffer',
'createShader',
'createTexture',
];
for (let ii = 0; ii < creationFunctions.length; ++ii) {
const functionName = creationFunctions[ii];
wrapper_[functionName] = (function (f) {
return function () {
if (contextLost_) {
return null;
}
const obj = (f as (this: WebGLRenderingContext, mask: number) => any).apply(
ctx,
arguments as unknown as [number],
);
obj.__webglDebugContextLostId__ = contextId_;
resourceDb_.push(obj);
return obj;
};
})(ctx[functionName]);
}
const functionsThatShouldReturnNull: WebGLContextFuncKey[] = [
'getActiveAttrib',
'getActiveUniform',
'getBufferParameter',
'getContextAttributes',
'getAttachedShaders',
'getFramebufferAttachmentParameter',
'getParameter',
'getProgramParameter',
'getProgramInfoLog',
'getRenderbufferParameter',
'getShaderParameter',
'getShaderInfoLog',
'getShaderSource',
'getTexParameter',
'getUniform',
'getUniformLocation',
'getVertexAttrib',
];
for (let ii = 0; ii < functionsThatShouldReturnNull.length; ++ii) {
const functionName = functionsThatShouldReturnNull[ii];
wrapper_[functionName] = (function (f) {
return function () {
if (contextLost_) {
return null;
}
return (f as (this: WebGLRenderingContext, mask: number) => any).apply(ctx, arguments as unknown as [number]);
};
})(wrapper_[functionName]);
}
const isFunctions: WebGLContextFuncKey[] = [
'isBuffer',
'isEnabled',
'isFramebuffer',
'isProgram',
'isRenderbuffer',
'isShader',
'isTexture',
];
for (let ii = 0; ii < isFunctions.length; ++ii) {
const functionName = isFunctions[ii];
wrapper_[functionName] = (function (f) {
return function () {
if (contextLost_) {
return false;
}
return (f as (this: WebGLRenderingContext, mask: number) => any).apply(ctx, arguments as unknown as [number]);
};
})(wrapper_[functionName]);
}
wrapper_.checkFramebufferStatus = (function (f) {
return function () {
if (contextLost_) {
return ctx.FRAMEBUFFER_UNSUPPORTED;
}
return (f as (this: WebGLRenderingContext, mask: number) => any).apply(ctx, arguments as unknown as [number]);
};
})(wrapper_.checkFramebufferStatus);
wrapper_.getAttribLocation = (function (f) {
return function () {
if (contextLost_) {
return -1;
}
return (f as (this: WebGLRenderingContext, mask: number) => any).apply(ctx, arguments as unknown as [number]);
};
})(wrapper_.getAttribLocation);
wrapper_.getVertexAttribOffset = (function (f) {
return function () {
if (contextLost_) {
return 0;
}
return (f as (this: WebGLRenderingContext, mask: number) => any).apply(ctx, arguments as unknown as [number]);
};
})(wrapper_.getVertexAttribOffset);
wrapper_.isContextLost = function () {
return contextLost_;
};
function wrapEvent(listener: Function | { handleEvent: Function }) {
if (typeof listener == 'function') {
return listener;
} else {
return function (info: any) {
listener.handleEvent(info);
};
}
}
wrapper_.registerOnContextLostListener = function (listener: Function) {
onLost_ = wrapEvent(listener);
};
wrapper_.registerOnContextRestoredListener = function (listener: Function) {
if (contextLost_) {
nextOnRestored_ = wrapEvent(listener);
} else {
onRestored_ = wrapEvent(listener);
}
};
return wrapper_;
}
return {
/**
* Initializes this module. Safe to call more than once.
* @param {!WebGLRenderingContext} ctx A WebGL context. If
* you have more than one context it doesn't matter which one
* you pass in, it is only used to pull out constants.
*/
init: init,
/**
* Returns true or false if value matches any WebGL enum
* @param {*} value Value to check if it might be an enum.
* @return {boolean} True if value matches one of the WebGL defined enums
*/
mightBeEnum: mightBeEnum,
/**
* Gets an string version of an WebGL enum.
*
* Example:
* WebGLDebugUtil.init(ctx);
* const str = WebGLDebugUtil.glEnumToString(ctx.getError());
*
* @param {number} value Value to return an enum for
* @return {string} The string version of the enum.
*/
glEnumToString: glEnumToString,
/**
* Converts the argument of a WebGL function to a string.
* Attempts to convert enum arguments to strings.
*
* Example:
* WebGLDebugUtil.init(ctx);
* const str = WebGLDebugUtil.glFunctionArgToString('bindTexture', 0, gl.TEXTURE_2D);
*
* would return 'TEXTURE_2D'
*
* @param {string} functionName the name of the WebGL function.
* @param {number} argumentIndx the index of the argument.
* @param {*} value The value of the argument.
* @return {string} The value as a string.
*/
glFunctionArgToString: glFunctionArgToString,
/**
* Given a WebGL context returns a wrapped context that calls
* gl.getError after every command and calls a function if the
* result is not NO_ERROR.
*
* You can supply your own function if you want. For example, if you'd like
* an exception thrown on any GL error you could do this
*
* function throwOnGLError(err, funcName, args) {
* throw WebGLDebugUtils.glEnumToString(err) + " was caused by call to" +
* funcName;
* };
*
* ctx = WebGLDebugUtils.makeDebugContext(
* canvas.getContext("webgl"), throwOnGLError);
*
* @param {!WebGLRenderingContext} ctx The webgl context to wrap.
* @param {!function(err, funcName, args): void} opt_onErrorFunc The function
* to call when gl.getError returns an error. If not specified the default
* function calls console.log with a message.
*/
makeDebugContext: makeDebugContext,
/**
* Given a WebGL context returns a wrapped context that adds 4
* functions.
*
* ctx.loseContext:
* simulates a lost context event.
*
* ctx.restoreContext:
* simulates the context being restored.
*
* ctx.registerOnContextLostListener(listener):
* lets you register a listener for context lost. Use instead
* of addEventListener('webglcontextlostevent', listener);
*
* ctx.registerOnContextRestoredListener(listener):
* lets you register a listener for context restored. Use
* instead of addEventListener('webglcontextrestored',
* listener);
*
* @param {!WebGLRenderingContext} ctx The webgl context to wrap.
*/
makeLostContextSimulatingContext: makeLostContextSimulatingContext,
/**
* Resets a context to the initial state.
* @param {!WebGLRenderingContext} ctx The webgl context to
* reset.
*/
resetToInitialState: resetToInitialState,
};
})();

View File

@ -0,0 +1,220 @@
/*
* Copyright 2010, Google Inc.
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are
* met:
*
* * Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* * Redistributions in binary form must reproduce the above
* copyright notice, this list of conditions and the following disclaimer
* in the documentation and/or other materials provided with the
* distribution.
* * Neither the name of Google Inc. nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
/**
* @fileoverview This file contains functions every webgl program will need
* a version of one way or another.
*
* Instead of setting up a context manually it is recommended to
* use. This will check for success or failure. On failure it
* will attempt to present an approriate message to the user.
*
* gl = WebGLUtils.setupWebGL(canvas);
*
* For animated WebGL apps use of setTimeout or setInterval are
* discouraged. It is recommended you structure your rendering
* loop like this.
*
* function render() {
* window.requestAnimationFrame(render, canvas);
*
* // do rendering
* ...
* }
* render();
*
* This will call your rendering function up to the refresh rate
* of your display but will stop rendering if your app is not
* visible.
*/
export const WebGLUtils = (function () {
/**
* Creates the HTLM for a failure message
* @param {string} canvasContainerId id of container of th
* canvas.
* @return {string} The html.
*/
const makeFailHTML = function (msg: string) {
return (
'' +
'<div style="margin: auto; width:500px;z-index:10000;margin-top:20em;text-align:center;">' +
msg +
'</div>'
);
};
/**
* Mesasge for getting a webgl browser
* @type {string}
*/
const GET_A_WEBGL_BROWSER =
'' +
'This page requires a browser that supports WebGL.<br/>' +
'<a href="http://get.webgl.org">Click here to upgrade your browser.</a>';
/**
* Mesasge for need better hardware
* @type {string}
*/
const OTHER_PROBLEM =
'' +
"It doesn't appear your computer can support WebGL.<br/>" +
'<a href="http://get.webgl.org">Click here for more information.</a>';
/**
* Creates a webgl context. If creation fails it will
* change the contents of the container of the <canvas>
* tag to an error message with the correct links for WebGL.
* @param {Element} canvas. The canvas element to create a
* context from.
* @param {WebGLContextCreationAttirbutes} opt_attribs Any
* creation attributes you want to pass in.
* @param {function:(msg)} opt_onError An function to call
* if there is an error during creation.
* @return {WebGLRenderingContext} The created context.
*/
const setupWebGL = function (
canvas: HTMLCanvasElement,
opt_attribs?: any,
opt_onError?: Function
) {
function handleCreationError(msg: string) {
const container = document.getElementsByTagName('body')[0];
//const container = canvas.parentNode;
if (container) {
let str = window.WebGLRenderingContext
? OTHER_PROBLEM
: GET_A_WEBGL_BROWSER;
if (msg) {
str += '<br/><br/>Status: ' + msg;
}
container.innerHTML = makeFailHTML(str);
}
}
opt_onError = opt_onError || handleCreationError;
if (canvas.addEventListener) {
canvas.addEventListener(
'webglcontextcreationerror',
(ev) => {
opt_onError = opt_onError || handleCreationError;
// WebGLContextEvent还不支持
opt_onError((ev as WebGLContextEvent).statusMessage);
},
false
);
}
const context = create3DContext(canvas, opt_attribs);
if (!context) {
if (!window.WebGLRenderingContext) {
opt_onError('');
} else {
opt_onError('');
}
}
return context;
};
/**
* Creates a webgl context.
* @param {!Canvas} canvas The canvas tag to get context
* from. If one is not passed in one will be created.
* @return {!WebGLContext} The created context.
*/
const create3DContext = function (
canvas: HTMLCanvasElement,
glAttributes: any
) {
const names = ['webgl', 'experimental-webgl', 'webkit-3d', 'moz-webgl'];
let context: WebGLRenderingContext | null = null;
for (let ii = 0; ii < names.length; ++ii) {
try {
context = canvas.getContext(
names[ii],
glAttributes
) as WebGLRenderingContext;
} catch (e) {}
if (context) {
break;
}
}
return context;
};
return {
create3DContext: create3DContext,
setupWebGL: setupWebGL,
};
})();
/**
* Provides requestAnimationFrame in a cross browser
* way.
*/
if (!window.requestAnimationFrame) {
window.requestAnimationFrame = (function () {
return (
window.requestAnimationFrame ||
(window as any).webkitRequestAnimationFrame ||
(window as any).mozRequestAnimationFrame ||
(window as any).oRequestAnimationFrame ||
(window as any).msRequestAnimationFrame ||
function (
/* function FrameRequestCallback */ callback: Function,
/* DOMElement Element */ element: Element
) {
window.setTimeout(callback, 1000 / 60);
}
);
})();
}
/** * ERRATA: 'cancelRequestAnimationFrame' renamed to 'cancelAnimationFrame' to reflect an update to the W3C Animation-Timing Spec.
*
* Cancels an animation frame request.
* Checks for cross-browser support, falls back to clearTimeout.
* @param {number} Animation frame request. */
if (!window.cancelAnimationFrame) {
window.cancelAnimationFrame =
(window as any).cancelRequestAnimationFrame ||
(window as any).webkitCancelAnimationFrame ||
(window as any).webkitCancelRequestAnimationFrame ||
(window as any).mozCancelAnimationFrame ||
(window as any).mozCancelRequestAnimationFrame ||
(window as any).msCancelAnimationFrame ||
(window as any).msCancelRequestAnimationFrame ||
(window as any).oCancelAnimationFrame ||
(window as any).oCancelRequestAnimationFrame ||
window.clearTimeout;
}

View File

@ -0,0 +1,27 @@
{
"name": "@palxp/image-extraction",
"version": "1.2.4",
"description": "TODO",
"author": "ShawnPhang <palxiao@vip.qq.com>",
"homepage": "https://fe-doc.palxp.cn/#/image-extraction",
"license": "ISC",
"main": "index.ts",
"module": "index.ts",
"types": "types/matting.d.ts",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/palxiao/front-end-arsenal.git"
},
"scripts": {
"test": "echo \"Error: run tests from root\" && exit 1"
},
"bugs": {
"url": "https://github.com/palxiao/front-end-arsenal/issues"
},
"dependencies": {
"throttle-debounce": "^5.0.0"
}
}

View File

@ -0,0 +1,98 @@
import { Ref } from 'vue';
/** 鼠标指针移动距离 */
export interface MouseMovements {
/** 水平移动距离 */
movementX: number;
/** 垂直移动距离 */
movementY: number;
}
/** 像素坐标 */
export interface PixelPosition {
x: number;
y: number;
}
/** 变换配置对象 */
export interface TransformConfig {
positionRange: PositionRange;
scaleRatio: number;
}
/** 绘制位置范围 */
export interface PositionRange {
minX: number;
maxX: number;
minY: number;
maxY: number;
}
/** 矩形的尺寸 */
export interface RectSize {
width: number;
height: number;
}
/** 画板矩形的参数 */
export interface BoardRect extends RectSize {
left: number;
top: number;
}
/** 矩形的尺寸 */
export interface RectSize {
width: number;
height: number;
}
/** 画板绘制上下文 */
export interface BoardDrawingContexts {
/** 画板绘制上下文 */
ctx: Ref<CanvasRenderingContext2D | null>;
/** 绘制输入图像的隐藏画板的绘制上下文 */
hiddenCtx: Ref<CanvasRenderingContext2D>;
/** 绘制画笔形状的临时画板 */
drawingCtx: CanvasRenderingContext2D;
}
/** 画板绘制上下文对象 */
export interface BoardContext2Ds {
/** 输入画板绘制上下文 */
inputCtx: Ref<CanvasRenderingContext2D | null>;
/** 输出画板绘制上下文 */
outputCtx: Ref<CanvasRenderingContext2D | null>;
inputDrawingCtx: CanvasRenderingContext2D;
outputDrawingCtx: CanvasRenderingContext2D;
/** 绘制输入图像的隐藏画板的绘制上下文 */
inputHiddenCtx: Ref<CanvasRenderingContext2D>;
/** 绘制输出图像的隐藏画板的绘制上下文 */
outputHiddenCtx: Ref<CanvasRenderingContext2D>;
}
/** 绘制基础配置对象 */
export interface MattingBoardBaseConfig {
boardContexts: BoardContext2Ds;
/** 画布目标尺寸 */
targetSize: RectSize;
/** 图像绘制时与画布边缘最小间隙 */
gapSize?: GapSize;
}
/**
*
* 80px40px
* 80px时40px时80px为准
*/
export interface GapSize {
horizontal: number;
vertical: number;
}
/** 初始化按照变换配置绘制图像的基础配置对象 */
export interface InitTransformedDrawBaseConfig {
/** 变换配置 */
transformConfig: TransformConfig;
/** 是否绘制图像边框 */
withBorder?: boolean;
}

View File

@ -0,0 +1,19 @@
import { Ref } from 'vue';
/** 鼠标指针的样式 */
export interface CursorStyle {
display?: string;
left?: string;
top?: string;
cursor?: string;
width?: string;
}
/** 使用鼠标指针组合API的配置对象 */
export interface UseCursorConfig {
inputCtx: Ref<CanvasRenderingContext2D | null>;
isDragging: Ref<boolean>;
isErasing: Ref<boolean>;
radius: Ref<number>;
hardness: Ref<number>;
}

View File

@ -0,0 +1,73 @@
import { InitTransformedDrawBaseConfig, PositionRange, RectSize, TransformConfig } from './common';
/** canvas尺寸重置配置对象 */
export interface ResizeCanvasConfig extends InitTransformedDrawBaseConfig {
/** canvas 2D上下文 */
ctx: CanvasRenderingContext2D;
hiddenCtx: CanvasRenderingContext2D;
/** 尺寸重置的目标宽度 */
targetWidth: number;
/** 尺寸重置的目标高度 */
targetHeight: number;
}
/** 创建2D绘制上下文的配置对象 */
export interface CreateContext2DConfig {
targetSize?: RectSize;
cloneCanvas?: HTMLCanvasElement;
}
/** 初始化隐藏画板的配置对象 */
export interface InitHiddenBoardConfig {
targetSize: RectSize;
hiddenCtx: CanvasRenderingContext2D;
drawingCtx: CanvasRenderingContext2D;
}
/** 初始化隐藏画布并返回图像的配置对象 */
export interface InitHiddenBoardWithImageConfig extends InitHiddenBoardConfig {
imageSource: ImageBitmap;
}
/** 从画布获取图像资源的配置对象 */
export interface GetImageSourceConfig {
ctx: CanvasRenderingContext2D;
imageSource: ImageBitmap;
width: number;
height: number;
}
/** 画板变换时绘制的上下文对象 */
export interface DirectlyDrawingContext {
/** 画板绘制上下文 */
ctx: CanvasRenderingContext2D;
/** 隐藏的绘制上下文 */
hiddenCtx: CanvasRenderingContext2D;
}
/** 画板变换时绘制图像的配置对象 */
export interface TransformedDrawingImageConfig extends DirectlyDrawingContext, TransformConfig {
clearOld?: boolean;
withBorder?: boolean;
}
/** 绘制图像边框的配置对象 */
export interface DrawImageLineBorderConfig {
/** 边框的上下左右位置信息 */
positionRange: PositionRange;
ctx: CanvasRenderingContext2D;
/** 边框颜色 */
lineStyle: string;
lineWidth: number;
}
/** 绘制圆点的配置对象 */
export interface DrawingCircularConfig {
ctx: CanvasRenderingContext2D | CanvasRenderingContext2D;
x: number;
y: number;
radius: number;
hardness: number;
innerColor?: string;
outerColor?: string;
}

View File

@ -0,0 +1,101 @@
import ListenerManager from '../helpers/listener-manager'
import { Ref } from 'vue'
import { BoardContext2Ds, BoardDrawingContexts, BoardRect, MouseMovements, PixelPosition, PositionRange, TransformConfig } from './common'
import { ImageSources } from './init-matting'
/** 初始化抠图绘制的配置对象 */
export interface InitDrawingListenerConfig {
/** 监听器管理器 */
listenerManager: ListenerManager
/** 图像绘制源 */
imageSources: ImageSources
/** 画板绘制上下文 */
boardContexts: BoardContext2Ds
/** 初始化绘制的配置 */
initDrawingConfig: InitDrawingConfig
/** 是否为擦除画笔 */
isErasing: boolean
/** 是否正在拖动左侧输入区画板 */
draggingInputBoard: boolean
boardRect: BoardRect
}
/** 画笔绘制的基础配置 */
export interface BrushDrawingBaseConfig extends TransformConfig {
/** 画笔半径 */
radius: number
/** 绘制距离间隔(移动时移动距离大于step才会进行绘制) */
step: number
/** 插值绘制时的间隔步长 */
stepBase: number
/** 画笔硬度 */
hardness: number
}
/** 初始化绘制的配置对象 */
interface InitDrawingConfig {
radius: Ref<number>
hardness: Ref<number>
transformConfig: TransformConfig
}
/** 画板绘制配置 */
export interface BoardDrawingConfig extends BoardDrawingContexts {
mattingSource: ImageBitmap
}
/** 执行绘制的配置对象 */
export interface DrawingListenerConfig {
/** 画笔绘制的基础配置 */
brushDrawingBaseConfig: BrushDrawingBaseConfig
/** 画板矩形的参数 */
boardRect: BoardRect
mattingSources: ImageSources
/** 绘制输入上下文 */
inputBoardDrawingConfig: BoardDrawingConfig
/** 绘制输出上下文 */
outputBoardDrawingConfig: BoardDrawingConfig
/** 是否正在拖动左侧输入区画板 */
draggingInputBoard: boolean
/** 是否为擦除画笔 */
isErasing: boolean
}
/** 计算绘制点坐标位置及鼠标指针水平、垂直移动距离的配置对象 */
export interface ComputePositionAndMovementConfig {
ev: MouseEvent
scaleRatio: number
positionRange: PositionRange
left: number
top: number
}
/** 像素坐标及鼠标指针移动量 */
export type PositionAndMovements = MouseMovements & PixelPosition
/** 计算相对于真实图像尺寸的鼠标指针位置的配置对象 */
export interface ComputeRealPositionConfig {
/** 鼠标指针的pageX */
pageX: number
/** 鼠标指针的pageY */
pageY: number
/** 画布的pageX */
left: number
/** 画布的pageY */
top: number
/** 图像左边缘相对于画布左上角的x坐标 */
minX: number
/** 图像上边缘相对于画布左上角的y坐标 */
minY: number
/** 图像缩放比例 */
scaleRatio: number
}
/** 判断是否可以绘制并绑定鼠标事件监听器的配置对象 */
export interface CanDrawAndBindMouseListenerConfig {
ev: MouseEvent
boardRect: BoardRect
positionRange: PositionRange
/** 是否正在拖动左侧输入区画板 */
draggingInputBoard: boolean
}

View File

@ -0,0 +1,37 @@
import { BoardDrawingContexts, MouseMovements, PixelPosition, PositionRange } from './common';
import { BrushDrawingBaseConfig } from './drawing-listeners';
/** 抠图绘制的配置对象 */
export interface MattingDrawingConfig extends MouseMovements, BrushDrawingBaseConfig, BoardDrawingContexts, PixelPosition {
stepBase: number;
mattingSource: ImageBitmap;
/** 是否为擦除画笔 */
isErasing?: boolean;
}
export interface ComputedMovements {
unsignedMovementX: number;
unsignedMovementY: number;
maxMovement: number;
}
/** 处理插值的绘制点的配置对象 */
export interface RenderInterpolationConfig {
/** 绘制点的配置对象 */
drawingConfig: MattingDrawingConfig;
/** 无符号水平移动距离 */
unsignedMovementX: number;
/** 无符号水平移动距离 */
unsignedMovementY: number;
/** 无符号水平/垂直移动距离较大的那个 */
maxMovement: number;
}
/** 判断是否在图像范围内 */
export type InImageRangeConfig = PositionRange & PixelPosition;
/** 插值步长 */
export interface InterpolationStep {
stepX: number;
stepY: number;
}

View File

@ -0,0 +1,108 @@
import ListenerManager from '../helpers/listener-manager'
import { Ref } from 'vue'
import { BoardContext2Ds, BoardRect, GapSize, MattingBoardBaseConfig, PositionRange, RectSize, TransformConfig } from './common'
/** 抠图画板配置 */
export interface MattingProps {
picFile: Ref<File | null>
isErasing: Ref<boolean>
radius: Ref<number>
hardness: Ref<number>
}
/** 初始化得到的作为绘制源的图像资源 */
export interface ImageSources {
/** 原始图片的绘制数据(原始图片初始化结果) */
raw: ImageBitmap
/** 蒙版图片的绘制数据(蒙版图片初始化结果) */
mask: ImageBitmap
orig: ImageBitmap
}
/** 初始化画板得到的结果 */
export type InitMattingResult = ImageSources & TransformConfig
/** 初始化抠图的组合API的基础配置对象 */
export interface InitMattingBaseConfig {
boardContexts: BoardContext2Ds
initMattingResult: Ref<InitMattingResult | null>
transformConfig: TransformConfig
mattingSources: Ref<ImageSources | null>
boardRect: Ref<BoardRect | null>
initialized: Ref<boolean>
}
/** 初始化抠图画板的组合API的基础配置对象 */
export interface UseInitMattingBoardsConfig extends InitMattingBaseConfig {
width: Ref<number>
height: Ref<number>
}
/** 初始化抠图绘制事件监听器的基础配置对象 */
export interface UseInitListenersConfig extends InitMattingBaseConfig {
/** 是否正在拖动左侧输入区画板 */
draggingInputBoard: Ref<boolean>
/** 是否正在绘制中 */
isDrawing: Ref<boolean>
/** 事件管理器 */
listenerManager: ListenerManager
}
/** 抠图画板初始化配置对象 */
export interface InitMattingConfig extends MattingBoardBaseConfig {
picFile: File
transformConfig: Partial<TransformConfig>
imageSources: Partial<ImageSources>
}
/** 生成模板图像资源的配置对象 */
export interface GenerateMaskSourceConfig {
targetSize: RectSize
imageSource: TexImageSource
}
interface DrawingBoardsContexts {
/** 隐藏的绘制上下文,用于绘制真实尺寸的图像 */
hiddenCtx: CanvasRenderingContext2D
drawingCtx: CanvasRenderingContext2D
ctx: CanvasRenderingContext2D
}
/** 画板初始化配置对象 */
export interface InitDrawingBoardConfig extends DrawingBoardsContexts {
/** 图片链接地址 */
picFile: File
targetSize: RectSize
transformConfig: Partial<TransformConfig>
withBorder?: boolean
needDraw?: boolean
}
/** 计算抠图画板变换配置的配置对象 */
export interface ComputeTransformConfigConfig {
imageSource: ImageBitmap
targetSize: RectSize
gapSize?: GapSize
}
/** 获取有效变换参数对象的配置对象 */
export interface GetValidTransformParametersConfig extends ComputeTransformConfigConfig {
transformConfig: Partial<TransformConfig>
}
/** 计算变换参数(缩放因子、偏移坐标)的配置对象 */
export interface TransformParametersConfig {
/** 图片原始尺寸 */
imageSize: RectSize
/** 间隙尺寸 */
gapSize: GapSize
/** 图片绘制的目标尺寸 */
targetSize: RectSize
}
/** 计算好的变换参数 */
export interface TransformParameters {
/** 缩放因子 */
scaleRatio: number
positionRange: PositionRange
}

View File

@ -0,0 +1,44 @@
export interface MouseListenerContext {
/** 触发鼠标事件的目标DOM */
mouseTarget: HTMLElement;
/** mousemove监听器 */
move: (ev: MouseEvent) => void;
/** mousedown监听器 */
down?: (ev: MouseEvent) => void | boolean;
/** mouseup监听器 */
up?: (ev: MouseEvent) => void;
}
/** 用于解绑mousedown监听器、mouseup监听器的回调的配置对象 */
export interface UnbindDownUpConfig {
/** 解绑mousedown监听器的回调 */
unbindDown: VoidFunction;
/** 解绑mouseup监听器的回调 */
unbindUp: VoidFunction;
}
/** 事件监听配置对象 */
export interface ListenerConfig {
/** 事件类型 */
eventType: string;
/** 事件监听器 */
listener: EventListener;
stop?: boolean;
prevent?: boolean;
}
export interface WheelListenerContext {
/** 输入端(左侧)画板 */
mattingBoards: HTMLCanvasElement[];
/** 滑动开始的监听器 */
wheel: (ev: WheelEvent) => void;
}
/** 存放UnbindDownUpConfig对象的容器 */
export type UnbindDownUpCache = WeakMap<HTMLElement, UnbindDownUpConfig>;
/** 存放解绑mousemove监听器的回调的容器 */
export type UnbindMoveCache = WeakMap<HTMLElement, VoidFunction>;
/** 解绑Wheel监听器的回调的容器 */
export type UnbindWheelCache = Set<VoidFunction>;

View File

@ -0,0 +1 @@
export type GLColor = [number, number, number, number];

View File

@ -0,0 +1,32 @@
export interface MattingType {
value: {}
/** 是否为擦除画笔 */
isErasing: boolean
/** 下载结果图 */
onDownloadResult: Function
/** 返回结果图 */
getResult: Function
/** input表单选择文件的回调 */
onFileChange: Function
/**
*
*/
initLoadImages: Function
/** 画笔尺寸 */
radius: number | string
/** 画笔尺寸:计算属性,显示值 */
brushSize: any
/** 画笔硬度 */
hardness: number | string
/** 画笔硬度:计算属性,显示值 */
hardnessText: any
/** 常量 */
constants: {
RADIUS_SLIDER_MIN: number
RADIUS_SLIDER_MAX: number
RADIUS_SLIDER_STEP: number
HARDNESS_SLIDER_MAX: number
HARDNESS_SLIDER_STEP: number
HARDNESS_SLIDER_MIN: number
}
}

View File

@ -0,0 +1,34 @@
/*
* @Author: ShawnPhang
* @Date: 2023-10-05 16:33:07
* @Description:
* @LastEditors: ShawnPhang <https://m.palxp.cn>
* @LastEditTime: 2023-10-08 11:11:01
*/
import ListenerManager from '../helpers/listener-manager'
import { InitTransformedDrawBaseConfig, PositionRange } from './common'
import { DirectlyDrawingContext } from './dom'
export interface InitMattingTransformConfig extends InitTransformedDrawBaseConfig {
/** 输入画板变换时绘制的上下文对象 */
inputContexts: DirectlyDrawingContext
/** 输出画板变换时绘制的上下文对象 */
outputContexts: DirectlyDrawingContext
}
export interface InitMattingScaleConfig extends InitMattingTransformConfig {
listenerManager: ListenerManager
}
/** 初始化抠图画板变化的配置对象 */
export interface InitMattingDragConfig extends InitMattingScaleConfig {
/** 是否正在拖动左侧输入区画板 */
draggingInputBoard: boolean
}
/** 生成绘制返回偏移量的配置对象 */
export interface GenerateRangeOffsetConfig {
pageX: number
pageY: number
positionRange: PositionRange
}

View File

@ -1,4 +1,3 @@
## Node截图服务
目录结构比较简单,主要就实现了三个接口,其中 `api/screenshots` 即是项目中所使用到的图片生成接口,在真实生产项目中可以把该服务单独部署,于内网调用,这样利于做一些鉴权之类的处理。
@ -7,7 +6,17 @@
### 安装依赖
`npm install``yarn`
`npm install`
安装依赖时可能会出现这个报错提示:
```
ERROR: Failed to set up Chromium xxx! Set "PUPPETEER_SKIP_DOWNLOAD" env variable to skip download.
```
不用慌,这是因为 puppeteer 会自动下载 Chromium国内会受到网络波动的影响。
如果跳过的话需要手动安装,比较麻烦所以并不推荐。解决方法是多尝试几次,或者更换国内的镜像源即可。
### 启动项目并热更新
@ -17,36 +26,24 @@
`npm run build`
#### 打包说明
#### 打包部署步骤
直接打包可能会出现未知错误,本项目在 **webpack.config.js** 中过滤掉了依赖(打包出来的文件会非常小因为只包含项目代码),建议将 `package.json` 放到服务器上主动安装依赖来使用,具体的做法类似以下步骤:
> 服务器环境需求Node.js 16.18.1版本不同则可能出现错误、PM2进程守护
1. 将项目中的 `package-build.json` 上传到服务器中,重命名为 `package.json`
2. 目录下执行 `yarn` 安装依赖
3. 执行 `npm run build`
4. 将打包的 `dist/server.js` 放在 `node_modules` 同级目录中
5. 使用 `pm2 start server.js` 启动并守护服务
1. 本地执行 `npm run build` 打包
2. 打包后项目根目录 `dist/` 文件夹上传服务器,并执行 `npm install` 安装依赖
3. 运行 `pm2 start dist/server.js` 启动并守护服务
### 服务器配置
### 配置说明
在 Linux 环境下推荐主动安装浏览器,再给项目中的配置文件 `src/config.ts` 设置好路径
配置文件 `src/config.ts` 配置项说明
```js
exports.executablePath = '/opt/google/chrome-unstable/chrome'
port // 端口号
website // 编辑器项目的地址
filePath // 生成图片保存的目录
```
> `/opt/google` 为默认路径,一般不会变动
一些可能用到的 linux 命令参考Debian GNU/Linux 9
```shell
google-chrome --version # 查看浏览器版本号
apt-get update
apt-get install -y google-chrome-stable // 安装最新稳定版谷歌浏览器
```
> 其它系统自行搜索如何安装Chrome推荐使用Docker部署本项目部署[参考说明](https://xp.palxp.cn/#/articles/1689319644311?id=docker%e5%ae%b9%e5%99%a8)。
### 生成 API 文档
`build:apidoc`

View File

@ -1,30 +0,0 @@
var gulp = require('gulp');
var exec = require('child_process').exec;
var spawn = require('child_process').spawn;
var path = require('path');
gulp.task('clean', function() {
return spawn('rm', ['-rf', path.join(__dirname, 'dist')])
});
gulp.task('build-ts', function(){
return exec('webpack --watch',(error,stdout,stderr)=>{
console.log(`build ts====>stdout: ${stdout}`);
console.log(`build ts====>stderr: ${stderr}`);
if (error !== null) {
console.log(`exec error: ${error}`);
}
});
});
//自动重启服务器
gulp.task('restart',function(){
return exec('supervisor -w dist ./dist/main.js',(error,stdout,stderr)=>{
console.log(`restart=====>stdout: ${stdout}`);
console.log(`restart=====>stderr: ${stderr}`);
if (error !== null) {
console.log(`exec error: ${error}`);
}
});
});
gulp.task('default',gulp.series('clean',gulp.parallel('build-ts','restart')));

View File

@ -1,23 +0,0 @@
{
"name": "screenshot-node",
"version": "1.0.0",
"description": "",
"main": "./src/main",
"scripts": {},
"author": "ShawnPhang",
"license": "ISC",
"dependencies": {
"axios": "^0.24.0",
"body-parser": "^1.19.0",
"express": "^4.17.1",
"fontmin": "^1.0.1",
"form-data": "^4.0.0",
"moment": "^2.18.1",
"multiparty": "^4.2.3",
"mysql": "^2.13.0",
"qiniu": "^7.4.0",
"puppeteer": "^10.4.0",
"images": "3.2.3"
},
"devDependencies": {}
}

File diff suppressed because it is too large Load Diff

View File

@ -4,15 +4,12 @@
"description": "",
"main": "./src/main",
"scripts": {
"dev": "ts-node-dev src/main",
"test": "cd ./static && hs --cors",
"dev": "cross-env NODE_ENV=development ts-node-dev src/main",
"build": "cross-env NODE_ENV=production webpack",
"serve": "ts-node src/main",
"serve-test": "gulp",
"start": "webpack --watch",
"serverstart": "pm2 start ./dist/server.js --watch",
"tscstart": "tsc -w",
"serverstart2": "supervisor -w www ./www/main.js",
"build:apidoc": "apidoc -i src/ -o _apidoc/",
"publish": "rm -rf ./static && rm -rf ./_apidoc && sh script/publish.sh",
"publish-fast": "git add . && git commit -m 'build: auto publish' && npm run publish"
@ -22,27 +19,18 @@
"dependencies": {
"body-parser": "^1.19.0",
"express": "^4.17.1",
"gif-encoder-2": "^1.0.5",
"images": "^3.2.4",
"png-js": "^1.0.0",
"puppeteer": "^10.4.0"
},
"devDependencies": {
"@types/node": "^12.6.9",
"@types/node": "^16.18.87",
"cross-env": "^7.0.3",
"express-graphql": "^0.9.0",
"graphql": "^14.5.4",
"gulp": "^4.0.2",
"http-server": "^14.0.0",
"speed-measure-webpack-plugin": "^1.5.0",
"supervisor": "^0.12.0",
"ts-loader": "^6.0.4",
"ts-node": "^8.3.0",
"ts-node-dev": "^1.0.0-pre.40",
"ts-node-dev": "^2.0.0",
"typescript": "^3.5.3",
"webpack": "^4.39.1",
"webpack-bundle-analyzer": "^4.5.0",
"webpack-cli": "^3.3.6",
"webpack": "^5.90.3",
"webpack-cli": "^5.1.4",
"webpack-node-externals": "^3.0.0"
},
"apidoc": {

View File

@ -3,21 +3,36 @@
* @Date: 2022-02-01 13:41:59
* @Description:
* @LastEditors: ShawnPhang <https://m.palxp.cn>
* @LastEditTime: 2023-09-15 10:40:41
* @LastEditTime: 2023-12-06 19:17:27
*/
const isDev = process.env.NODE_ENV === 'development'
exports.servicePort = 7001
// 服务器常用修改项
const serviceComfig = {
port: 7001, // 端口号
website: 'https://design.palxp.cn', // 编辑器项目的地址
filePath: '/cache/' // 生成图片保存的目录
}
/**
* chrome浏览器位置
*
*/
exports.executablePath = '/opt/google/chrome-unstable/chrome',
exports.servicePort = serviceComfig.port
/**
*
*/
exports.drawLink = isDev ? 'http://localhost:3000/draw' : 'https://design.palxp.cn/draw'
exports.drawLink = isDev ? 'http://localhost:5173/draw' : serviceComfig.website + '/draw'
/**
*
*/
exports.filePath = isDev ? process.cwd() + `/static/` : serviceComfig.filePath
/**
* chrome浏览器位置
*/
exports.executablePath = isDev ? null : '/opt/google/chrome-unstable/chrome'
/**
*
@ -33,9 +48,3 @@ exports.upperLimit = 20
*
*/
exports.releaseTime = 300
/**
*
*/
exports.filePath = isDev ? process.cwd() + `/static/` : '/cache/'
// exports.filePath = process.cwd() + `/static/`

View File

@ -49,4 +49,4 @@ app.use(bodyParser.json())
app.use(router)
app.listen(port, () => console.log(`devServer start on port:${port}`))
app.listen(port, () => console.log(`Screenshot Server start on port:${port}`))

View File

@ -3,7 +3,7 @@
* @Date: 2020-07-22 20:13:14
* @Description:
* @LastEditors: ShawnPhang <https://m.palxp.cn>
* @LastEditTime: 2023-10-16 10:03:51
* @LastEditTime: 2023-12-07 12:23:57
*/
const { saveScreenshot } = require('../utils/download-single.ts')
const uuid = require('../utils/uuid.ts')
@ -68,7 +68,6 @@ module.exports = {
return
}
const targetUrl = url + id + `${tempType?'&tempType='+tempType:''}`
// console.log(targetUrl, path, thumbPath);
queueRun(saveScreenshot, targetUrl, { width, height, path, thumbPath, size, quality })
.then(() => {
res.setHeader('Content-Type', 'image/jpg')

View File

@ -22,17 +22,22 @@ const saveScreenshot = async (url: string, { path, width, height, thumbPath, siz
// 格式化浏览器宽高
width = Number(width).toFixed(0)
height = Number(height).toFixed(0)
const puppeteerArgs = {
old: ['no-first-run', '--no-sandbox', '--disable-setuid-sandbox', `--window-size=${width},${height}`, 'single-process', 'disable-gpu', 'no-zygote', 'disable-dev-shm-usage'],
new: [ 'no-first-run', '--no-sandbox', '--disable-setuid-sandbox', `--window-size=${width},${height}` ]
}
// 启动浏览器
try {
browser = await puppeteer.launch({
headless: true, // !isDev,
executablePath: isDev ? null : executablePath,
executablePath,
ignoreHTTPSErrors: true, // 忽略https安全提示
args: ['no-first-run', 'single-process', 'disable-gpu', 'no-zygote', 'disable-dev-shm-usage', '--no-sandbox', '--disable-setuid-sandbox', `--window-size=${width},${height}`], // 优化配置
args: puppeteerArgs.old, // 如puppeteer版本v20+报错请尝试使用新参数
defaultViewport: null,
})
} catch (error) {
console.log('Puppeteer启动错误!', '窗口大小:', width, height);
console.log('Puppeteer Error: ', error, '窗口大小:', width, height);
}
if (!browser) {
reject()

View File

@ -10,6 +10,7 @@
const path = require('path')
// const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin
const nodeExternals = require('webpack-node-externals');
const buildPlugin = require('./webpack.plugin.js');
module.exports = {
mode: process.env.NODE_ENV,
@ -39,4 +40,5 @@ module.exports = {
],
},
// plugins: [new BundleAnalyzerPlugin()],
plugins: [ new buildPlugin() ]
}

View File

@ -0,0 +1,24 @@
const pkg = require("./package.json");
const fs = require('fs');
class MyPlugin {
apply(compiler) {
compiler.hooks.emit.tap("BuildPackageJson", (compilation) => {
console.log("构建 package.json ....");
const myBuildPackageJson = `{
name: ${pkg.name+'-builder'},
version: ${pkg.version},
dependencies: ${JSON.stringify(pkg.dependencies, null, 2)}
}`;
fs.writeFile('./dist/package.json', myBuildPackageJson, 'utf8', (err) => {
if (err) {
console.error('保存 package.json 文件时发生错误:', err);
} else {
console.log('package.json 文件构建完成!');
}
});
});
}
}
module.exports = MyPlugin;

File diff suppressed because it is too large Load Diff

View File

@ -22,7 +22,7 @@
<script setup lang="ts">
import api from '@/api'
import { ElDialog } from 'element-plus'
import { ref, defineEmits, reactive, nextTick, toRefs } from 'vue'
import { ref, reactive, nextTick, toRefs } from 'vue'
import { useStore } from 'vuex'
import 'cropperjs/dist/cropper.css'
import Cropper from 'cropperjs'

View File

@ -40,7 +40,7 @@
</template>
<script lang="ts" setup>
import { reactive, nextTick, defineEmits, ref } from 'vue'
import { reactive, nextTick, ref } from 'vue'
import { useStore } from 'vuex'
import { ElProgress } from 'element-plus'
import { UploadFilled } from '@element-plus/icons-vue'

View File

@ -31,7 +31,7 @@
</template>
<script lang="ts" setup>
import { reactive, defineEmits, defineExpose } from 'vue'
import { reactive } from 'vue'
import { useStore } from 'vuex'
import { ElTabPane, ElTabs, TabPaneName } from 'element-plus'
import api from '@/api'

View File

@ -2,7 +2,7 @@
* @Author: ShawnPhang
* @Date: 2022-03-16 09:15:52
* @Description:
* @LastEditors: ShawnPhang <https://m.palxp.cn>, Jeremy Yu <https://github.com/JeremyYu-cn>
* @LastEditors: ShawnPhang <https://m.palxp.cn>
* @Date: 2024-03-04 18:50:00
-->
<template>
@ -10,7 +10,7 @@
</template>
<script lang="ts" setup>
import { onMounted, ref, watch, nextTick, defineProps } from 'vue'
import { onMounted, ref, watch, nextTick } from 'vue'
import QRCodeStyling, {DotType, Options } from 'qr-code-styling'
import { debounce } from 'throttle-debounce'
import { generateOption } from './method'

View File

@ -2,8 +2,8 @@
* @Author: ShawnPhang
* @Date: 2022-04-10 12:12:57
* @Description: tooltip提示
* @LastEditors: ShawnPhang
* @LastEditTime: 2022-04-10 12:42:02
* @LastEditors: ShawnPhang <https://m.palxp.cn>
* @LastEditTime: 2024-03-11 01:41:20
-->
<template>
<el-popover ref="popover" :placement="position" :title="title" :width="width" trigger="hover" :content="content">
@ -14,8 +14,6 @@
</template>
<script lang="ts" setup>
import { defineProps } from 'vue'
type TProps = {
title: string
width: number

View File

@ -2,7 +2,7 @@
* @Author: ShawnPhang
* @Date: 2021-12-28 09:29:42
* @Description: 百分比进度条
* @LastEditors: ShawnPhang <site: book.palxp.com>, Jeremy Yu <https://github.com/JeremyYu-cn>
* @LastEditors: ShawnPhang <https://m.palxp.cn>
* @Date: 2024-03-05 10:50:00
-->
<template>
@ -17,7 +17,7 @@
</template>
<script lang="ts" setup>
import { watch, defineProps, defineEmits } from 'vue'
import { watch } from 'vue'
import { ElProgress } from 'element-plus'
type TProps = {

View File

@ -2,7 +2,7 @@
* @Author: ShawnPhang
* @Date: 2021-08-29 18:17:13
* @Description: 二次封装上传组件
* @LastEditors: ShawnPhang <https://m.palxp.cn>, Jeremy Yu <https://github.com/JeremyYu-cn>
* @LastEditors: ShawnPhang <https://m.palxp.cn>
* @Date: 2024-03-05 10:50:00
-->
<template>
@ -14,7 +14,7 @@
</template>
<script lang="ts" setup>
import { onMounted, nextTick, defineProps, withDefaults, defineEmits } from 'vue'
import { onMounted, nextTick, withDefaults } from 'vue'
import { ElUpload, UploadRequestOptions } from 'element-plus'
import Qiniu from '@/common/methods/QiNiu'
import { getImage } from '@/common/methods/getImgDetail'

View File

@ -58,7 +58,7 @@
</template>
<script lang="ts" setup>
import { nextTick, defineProps, onMounted, ref } from 'vue'
import { nextTick, onMounted, ref } from 'vue'
import { mapGetters, mapActions, useStore } from 'vuex'
import { getTarget } from '@/common/methods/target'

View File

@ -2,15 +2,15 @@
* @Author: ShawnPhang
* @Date: 2022-04-08 10:31:34
* @Description:
* @LastEditors: ShawnPhang <site: book.palxp.com>
* @LastEditTime: 2023-06-29 18:07:40
* @LastEditors: ShawnPhang <https://m.palxp.cn>
* @LastEditTime: 2024-03-11 01:42:25
-->
<template>
<div></div>
</template>
<script lang="ts" setup>
import { watch, defineProps } from 'vue'
import { watch } from 'vue'
import { useStore } from 'vuex'
import Guides, { GuideOptions } from '@scena/guides'

View File

@ -3,7 +3,7 @@
* @Date: 2021-08-27 15:16:07
* @Description: 背景图
* @LastEditors: ShawnPhang <https://m.palxp.cn>
* @LastEditTime: 2024-01-24 16:39:27
* @LastEditTime: 2024-03-11 01:42:36
-->
<template>
<div class="wrap">
@ -23,7 +23,7 @@
</template>
<script lang="ts" setup>
import { reactive, computed, defineProps, defineExpose } from 'vue'
import { reactive, computed } from 'vue'
import api from '@/api'
import { useStore } from 'vuex'
import { ElImage } from 'element-plus'

View File

@ -236,9 +236,12 @@ export default defineComponent({
width: 100%;
// padding: 20px 0 0 10px;
padding: 3.1rem 0 0 1rem;
gap: 0 !important;
&__item {
overflow: hidden;
background: #f8fafc;
margin-bottom: 8px;
margin-right: 8px;
}
&__img {
cursor: grab;

View File

@ -21,7 +21,7 @@
</template>
<script lang="ts" setup>
import { reactive, ref, defineExpose } from 'vue'
import { reactive, ref } from 'vue'
import api from '@/api'
import { useStore } from 'vuex'
import { LocationQueryValue, useRoute, useRouter } from 'vue-router'

View File

@ -3,7 +3,7 @@
* @Date: 2022-02-13 22:18:35
* @Description: 我的
* @LastEditors: ShawnPhang <https://m.palxp.cn>
* @LastEditTime: 2023-12-11 11:50:34
* @LastEditTime: 2024-03-11 01:42:44
-->
<template>
<div class="wrap">
@ -36,7 +36,7 @@
</template>
<script lang="ts" setup>
import { reactive, toRefs, watch, nextTick, ref, onMounted, defineProps, defineExpose } from 'vue'
import { reactive, toRefs, watch, nextTick, ref, onMounted } from 'vue'
import { ElTabPane, ElTabs, TabPaneName } from 'element-plus'
import { useRouter } from 'vue-router'
import { useStore } from 'vuex'

View File

@ -2,7 +2,7 @@
* @Author: ShawnPhang
* @Date: 2023-10-04 02:04:04
* @Description: 列表分类头部
* @LastEditors: ShawnPhang <https://m.palxp.cn>, Jeremy Yu <https://github.com/JeremyYu-cn>
* @LastEditors: ShawnPhang <https://m.palxp.cn>
* @Date: 2024-03-06 21:16:00
-->
<template>
@ -22,8 +22,6 @@
</template>
<script lang="ts" setup>
import { defineProps, defineEmits } from 'vue'
export type TClassHeaderTypeData = {
name: string
}

View File

@ -2,7 +2,7 @@
* @Author: ShawnPhang
* @Date: 2023-10-04 19:12:40
* @Description: 图片描述ToolTip
* @LastEditors: ShawnPhang <https://m.palxp.cn>, Jeremy Yu <https://github.com/JeremyYu-cn>
* @LastEditors: ShawnPhang <https://m.palxp.cn>
* @Date: 2024-03-06 21:16:00
-->
<template>
@ -18,7 +18,6 @@
</template>
<script lang="ts" setup>
import { defineProps } from 'vue'
export type TImageTipDetailData = {
author: string

View File

@ -2,7 +2,7 @@
* @Author: ShawnPhang
* @Date: 2021-12-16 16:20:16
* @Description: 瀑布流组件
* @LastEditors: ShawnPhang <https://m.palxp.cn>, Jeremy Yu <https://github.com/JeremyYu-cn>
* @LastEditors: ShawnPhang <https://m.palxp.cn>
* @Date: 2024-03-06 21:16:00
-->
<template>
@ -27,7 +27,7 @@
<script lang="ts" setup>
// const NAME = 'img-water-fall'
import { IGetTempListData } from '@/api/home';
import { reactive, watch, defineProps, defineExpose, defineEmits } from 'vue'
import { reactive, watch } from 'vue'
type TProps = {
listData: IGetTempListData[]

View File

@ -2,7 +2,7 @@
* @Author: ShawnPhang
* @Date: 2022-02-23 15:48:52
* @Description: 图片列表组件 Bookshelf Layout
* @LastEditors: ShawnPhang <https://m.palxp.cn>, Jeremy Yu <https://github.com/JeremyYu-cn>
* @LastEditors: ShawnPhang <https://m.palxp.cn>
* @Date: 2024-03-06 21:16:00
-->
<template>
@ -39,7 +39,7 @@
</template>
<script lang="ts" setup>
import { reactive, watch, nextTick, defineProps, defineExpose, defineEmits, ref } from 'vue'
import { reactive, watch, nextTick, ref } from 'vue'
import DragHelper from '@/common/hooks/dragHelper'
import setImageData, { TItem2DataParam } from '@/common/methods/DesignFeatures/setImage'
import { IGetTempListData } from '@/api/home';

View File

@ -2,7 +2,7 @@
* @Author: ShawnPhang
* @Date: 2022-01-27 11:05:48
* @Description:
* @LastEditors: ShawnPhang <https://m.palxp.cn>, Jeremy Yu <https://github.com/JeremyYu-cn>
* @LastEditors: ShawnPhang <https://m.palxp.cn>
* @Date: 2024-03-06 21:16:00
-->
<template>
@ -30,7 +30,7 @@
</div>
</template>
<script lang="ts" setup>
import { reactive, toRefs, watch, defineProps, defineEmits, defineExpose } from 'vue'
import { reactive, toRefs, watch } from 'vue'
import { ElDropdown, ElDropdownItem, ElDropdownMenu } from 'element-plus'
import { useRoute } from 'vue-router'
import api from '@/api'

View File

@ -40,7 +40,7 @@
<script lang="ts" setup>
import api from '@/api'
import { toRefs, reactive, watch, onMounted, nextTick, defineProps, defineEmits, defineExpose } from 'vue'
import { toRefs, reactive, watch, onMounted, nextTick } from 'vue'
import { ElRadioGroup, ElRadioButton } from 'element-plus'
import wSvg from '@/components/modules/widgets/wSvg/wSvg.vue'
import { TGetListResult } from '@/api/material';

View File

@ -3,7 +3,7 @@
* @Date: 2021-08-09 14:00:23
* @Description: 文字特效选择框组件
* @LastEditors: ShawnPhang <https://m.palxp.cn>
* @LastEditTime: 2023-11-30 10:14:30
* @LastEditTime: 2024-03-11 01:43:21
-->
<template>
<el-card class="box-card" shadow="hover" :body-style="{ padding: '20px' }">
@ -109,8 +109,7 @@
<script lang="ts" setup>
import {
reactive, watch, onMounted, nextTick, computed,
defineProps, defineEmits, defineExpose
reactive, watch, onMounted, nextTick, computed
} from 'vue'
import colorSelect from '../colorSelect.vue'
import { ElInputNumber, ElCheckbox } from 'element-plus'

View File

@ -16,12 +16,8 @@
</template>
<script lang="ts" setup>
// const NAME = 'color-select'
import {reactive, onMounted, watch } from 'vue'
import { useStore } from 'vuex'
// import { debounce } from 'throttle-debounce'
// import { toolTip } from '@/common/methods/helper'
// import colorPicker from '@/utils/plugins/color-picker/index.vue'
import colorPicker from '@palxp/color-picker'
type TProps = {
@ -64,9 +60,7 @@ let first = true
onMounted(() => {
if (props.modelValue) {
let fixColor = props.modelValue + (props.modelValue.length === 7 ? 'ff' : '')
// @palxp/color-picker16
state.innerColor = fixColor.toLocaleUpperCase()
state.innerColor = props.modelValue + (props.modelValue.length === 7 ? 'ff' : '')
}
})

View File

@ -91,7 +91,7 @@ type TState = {
const props = withDefaults(defineProps<TProps>(), {
label: '',
modelValue: () => ({}),
suffic: '',
suffix: '',
data: () => ({}),
disable: true,
inputWidth: '80px',

View File

@ -26,7 +26,7 @@
//
const NAME = 'w-group'
import { nextTick, onBeforeUnmount, onMounted, onUpdated, ref } from 'vue'
import { mapGetters, mapActions, useStore } from 'vuex'
import { useStore } from 'vuex'
import { setTransformAttribute } from '@/common/methods/handleTransform'
import { useSetupMapGetters } from '@/common/hooks/mapGetters';
@ -55,26 +55,26 @@ const widget = ref<HTMLElement | null>(null)
const ratio = ref(0)
const temp = ref<Record<string, any>>({})
const compWidgetsRecord = ref<Record<string, any>>({})
const setting = {
name: '组合',
type: NAME,
uuid: -1,
width: 0,
height: 0,
left: 0,
top: 0,
transform: '',
opacity: 1,
parent: '-1',
isContainer: true,
record: {
width: 0,
height: 0,
minWidth: 0,
minHeight: 0,
dir: 'none',
},
}
// const setting = {
// name: '',
// type: NAME,
// uuid: -1,
// width: 0,
// height: 0,
// left: 0,
// top: 0,
// transform: '',
// opacity: 1,
// parent: '-1',
// isContainer: true,
// record: {
// width: 0,
// height: 0,
// minWidth: 0,
// minHeight: 0,
// dir: 'none',
// },
// }
const timer = ref<number | null>(null)
const { dActiveElement, dWidgets } = useSetupMapGetters(['dActiveElement', 'dWidgets'])
@ -235,9 +235,9 @@ function keySetValue(uuid: string, key: keyof TParamsData, value: number) {
}, 10)
}
defineExpose({
setting
})
// defineExpose({
// setting
// })
</script>

View File

@ -1,9 +1,9 @@
<template>
<div
:id="params.uuid"
:id="`${params.uuid}`"
ref="widget"
v-loading="loading"
:class="['w-text', { editing: editable, 'layer-lock': params.lock }, params.uuid]"
v-loading="state.loading"
:class="['w-text', { editing: state.editable, 'layer-lock': params.lock }, params.uuid]"
:style="{
position: 'absolute',
left: params.left - parent.left + 'px',
@ -27,17 +27,17 @@
}"
@dblclick="(e) => dblclickText(e)"
>
<template v-if="params.textEffects && !editable">
<template v-if="params.textEffects && !state.editable">
<div
v-for="(ef, efi) in params.textEffects"
:key="efi + 'effect'"
:style="{
fontFamily: `'${params.fontClass.value}'`,
color: ef.filling && ef.filling.enable && ef.filling.type === 0 ? ef.filling.color : 'transparent',
webkitTextStroke: ef.stroke && ef.stroke.enable ? `${ef.stroke.width}px ${ef.stroke.color}` : undefined,
WebkitTextStroke: ef.stroke && ef.stroke.enable ? `${ef.stroke.width}px ${ef.stroke.color}` : undefined,
textShadow: ef.shadow && ef.shadow.enable ? `${ef.shadow.offsetX}px ${ef.shadow.offsetY}px ${ef.shadow.blur}px ${ef.shadow.color}` : undefined,
backgroundImage: ef.filling && ef.filling.enable ? (ef.filling.type === 0 ? undefined : getGradientOrImg(ef)) : undefined,
webkitBackgroundClip: ef.filling && ef.filling.enable ? (ef.filling.type === 0 ? undefined : 'text') : undefined,
WebkitBackgroundClip: ef.filling && ef.filling.enable ? (ef.filling.type === 0 ? undefined : 'text') : undefined,
transform: ef.offset && ef.offset.enable ? `translate(${ef.offset.x}px, ${ef.offset.y}px)` : undefined,
}"
class="edit-text effect-text"
@ -45,203 +45,188 @@
v-html="params.text"
></div>
</template>
<div ref="editWrap" :style="{ fontFamily: `'${params.fontClass.value}'` }" class="edit-text" spellcheck="false" :contenteditable="editable ? 'plaintext-only' : false" @input="writingText($event)" @blur="writeDone($event)" v-html="params.text"></div>
<div
ref="editWrap" :style="{ fontFamily: `'${params.fontClass.value}'` }"
class="edit-text" spellcheck="false"
:contenteditable="state.editable ? 'plaintext-only' : false"
@input="writingText($event)"
@blur="writeDone($event)"
v-html="params.text"></div>
</div>
</template>
<script lang="ts">
<script lang="ts" setup>
//
const NAME = 'w-text'
// const NAME = 'w-text'
import { defineComponent, reactive, toRefs, computed, onUpdated, watch, onMounted, ref } from 'vue'
import { reactive, toRefs, computed, onUpdated, watch, onMounted, ref } from 'vue'
import { useStore } from 'vuex'
import { useRoute } from 'vue-router'
import { fontWithDraw } from '@/utils/widgets/loadFontRule'
import getGradientOrImg from './getGradientOrImg.ts'
import getGradientOrImg from './getGradientOrImg'
import { wTextSetting } from './wTextSetting'
export default defineComponent({
name: NAME,
setting: {
name: '文本',
type: NAME,
uuid: -1,
editable: false,
left: 0,
top: 0,
transform: '',
lineHeight: 1.5,
letterSpacing: 0,
fontSize: 24,
zoom: 1,
fontClass: {
alias: '站酷快乐体',
id: 543,
value: 'zcool-kuaile-regular',
url: 'https://lib.baomitu.com/fonts/zcool-kuaile/zcool-kuaile-regular.woff2',
},
fontFamily: 'SourceHanSansSC-Regular',
fontWeight: 'normal',
fontStyle: 'normal',
writingMode: 'horizontal-tb',
textDecoration: 'none',
color: '#000000ff',
textAlign: 'left',
text: '',
opacity: 1,
backgroundColor: '',
parent: '-1',
record: {
width: 0,
height: 0,
minWidth: 0,
minHeight: 0,
dir: 'horizontal',
},
},
props: ['params', 'parent'],
setup(props) {
const store = useStore()
const route = useRoute()
const state = reactive({
loading: false,
editable: false,
loadFontDone: false,
})
const widget = ref(null)
const editWrap = ref(null)
export type TwTextParams = {
rotate?: number
lock?: boolean
width?: number
height?: number
} & typeof wTextSetting
const dActiveElement = computed(() => store.getters.dActiveElement)
const isDraw = computed(() => route.name === 'Draw' && fontWithDraw)
type TProps = {
params: TwTextParams
parent: {
left: number
top: number
}
}
onUpdated(() => {
updateRecord()
})
const props = defineProps<TProps>()
onMounted(() => {
updateRecord()
props.params.transform && (widget.value.style.transform = props.params.transform)
props.params.rotate && (widget.value.style.transform += `translate(0px, 0px) rotate(${props.params.rotate}) scale(1, 1)`)
// store.commit('updateRect')
})
const store = useStore()
const route = useRoute()
const state = reactive({
loading: false,
editable: false,
loadFontDone: '',
})
const widget = ref<HTMLElement | null>(null)
const editWrap = ref<HTMLElement | null>(null)
watch(
() => props.params,
async (nval) => {
updateText()
if (state.loading) {
return
}
let font = nval.fontClass
const isDone = font.value === state.loadFontDone
const dActiveElement = computed(() => store.getters.dActiveElement)
const isDraw = computed(() => route.name === 'Draw' && fontWithDraw)
if (font.url && !isDone) {
if (font.id && isDraw.value) {
// url
// demobug
state.loading = false
return
}
state.loading = !isDraw.value
const loadFont = new window.FontFace(font.value, `url(${font.url})`)
await loadFont.load()
document.fonts.add(loadFont)
state.loadFontDone = font.value
state.loading = false
} else {
state.loading = false
}
},
{ immediate: true, deep: true },
)
onUpdated(() => {
updateRecord()
})
watch(
() => state.editable,
(value) => {
store.dispatch('updateWidgetData', {
uuid: props.params.uuid,
key: 'editable',
value,
pushHistory: false,
})
},
)
onMounted(() => {
updateRecord()
function updateRecord() {
if (dActiveElement.value.uuid === props.params.uuid) {
let record = dActiveElement.value.record
record.width = widget.value.offsetWidth
record.height = widget.value.offsetHeight
record.minWidth = props.params.fontSize
record.minHeight = props.params.fontSize * props.params.lineHeight
writingText()
if (!widget.value) return
props.params.transform && (widget.value.style.transform = props.params.transform)
props.params.rotate && (widget.value.style.transform += `translate(0px, 0px) rotate(${props.params.rotate}) scale(1, 1)`)
// store.commit('updateRect')
})
watch(
() => props.params,
async (nval) => {
updateText()
if (state.loading) {
return
}
let font = nval.fontClass
const isDone = font.value === state.loadFontDone
if (font.url && !isDone) {
if (font.id && isDraw.value) {
// url
// demobug
state.loading = false
return
}
}
function updateText(e) {
const value = e ? e.target.innerHTML : props.params.text.replace(/\n/g, '<br/>')
// const value = (e ? e.target.innerText : props.params.text).replace(/<br\/>/g, '\r\n').replace(/&nbsp;/g, ' ')
if (value !== props.params.text) {
store.dispatch('updateWidgetData', {
uuid: props.params.uuid,
key: 'text',
value,
pushHistory: false,
})
}
}
function writingText(e) {
// updateText(e)
// TODO:
const el = editWrap.value || widget.value
store.dispatch('updateWidgetData', {
uuid: props.params.uuid,
key: 'height',
value: el.offsetHeight,
pushHistory: false,
})
store.commit('updateRect')
}
function writeDone(e) {
state.editable = false
setTimeout(() => {
store.dispatch('pushHistory', '文字修改')
}, 100)
updateText(e)
}
function dblclickText(e) {
// store.commit('setShowMoveable', false)
state.editable = true
const el = editWrap.value || widget.value
setTimeout(() => {
el.focus()
if (document.selection) {
const range = document.body.createTextRange()
range.moveToElementText(el)
range.select()
} else if (window.getSelection) {
const range = document.createRange()
range.selectNodeContents(el)
window.getSelection().removeAllRanges()
window.getSelection().addRange(range)
}
}, 100)
}
return {
...toRefs(state),
getGradientOrImg,
updateRecord,
writingText,
updateText,
writeDone,
dblclickText,
widget,
editWrap,
state.loading = !isDraw.value
const loadFont = new window.FontFace(font.value, `url(${font.url})`)
await loadFont.load()
document.fonts.add(loadFont)
state.loadFontDone = font.value
state.loading = false
} else {
state.loading = false
}
},
{ immediate: true, deep: true },
)
watch(
() => state.editable,
(value) => {
store.dispatch('updateWidgetData', {
uuid: props.params.uuid,
key: 'editable',
value,
pushHistory: false,
})
},
)
function updateRecord() {
if (!widget.value) return
if (dActiveElement.value.uuid === props.params.uuid) {
let record = dActiveElement.value.record
record.width = widget.value.offsetWidth
record.height = widget.value.offsetHeight
record.minWidth = props.params.fontSize
record.minHeight = props.params.fontSize * props.params.lineHeight
writingText()
}
}
function updateText(e?: Event) {
const value = e && e.target ? (e.target as HTMLElement).innerHTML : props.params.text.replace(/\n/g, '<br/>')
// const value = (e ? e.target.innerText : props.params.text).replace(/<br\/>/g, '\r\n').replace(/&nbsp;/g, ' ')
if (value !== props.params.text) {
store.dispatch('updateWidgetData', {
uuid: props.params.uuid,
key: 'text',
value,
pushHistory: false,
})
}
}
function writingText(e?: Event) {
// updateText(e)
// TODO:
const el = editWrap.value || widget.value
if (!el) return
store.dispatch('updateWidgetData', {
uuid: props.params.uuid,
key: 'height',
value: el.offsetHeight,
pushHistory: false,
})
store.commit('updateRect')
}
function writeDone(e: Event) {
state.editable = false
setTimeout(() => {
store.dispatch('pushHistory', '文字修改')
}, 100)
updateText(e)
}
function dblclickText(_: MouseEvent) {
// store.commit('setShowMoveable', false)
state.editable = true
const el = editWrap.value || widget.value
setTimeout(() => {
if (!el) return
el.focus()
if (document.selection) {
const range = document.body.createTextRange()
range.moveToElementText(el)
range.select()
} else {
const range = document.createRange()
range.selectNodeContents(el);
window.getSelection()?.removeAllRanges();
window.getSelection()?.addRange(range);
}
}, 100)
}
defineExpose({
getGradientOrImg,
updateRecord,
writingText,
updateText,
writeDone,
dblclickText,
widget,
editWrap,
})
</script>

View File

@ -0,0 +1,101 @@
import { StyleValue } from "vue"
export type TwTextData = {
name: string
type: string
uuid: number
editable: boolean,
left: number
top: number
transform: string
lineHeight: number
letterSpacing: number
fontSize: number
zoom: number
fontClass: {
alias: string
id: number
value: string
url: string
},
fontFamily: string
fontWeight: string
fontStyle: string
writingMode: StyleProperty.WritingMode
textDecoration: string
color: string
textAlign: StyleProperty.TextAlign
text: string
opacity: number
backgroundColor: string
parent: string
record: {
width: number
height: number
minWidth: number
minHeight: number
dir: string
},
textEffects?: {
filling: {
enable: boolean
type: number
color: string
}
stroke: {
enable: boolean
width: number
color: string
}
shadow: {
enable: boolean
offsetY: number
offsetX: number
blur: number
color: string
}
offset: {
enable: boolean
x: number
y: number
}
}[]
}
export const wTextSetting: TwTextData = {
name: '文本',
type: 'w-text',
uuid: -1,
editable: false,
left: 0,
top: 0,
transform: '',
lineHeight: 1.5,
letterSpacing: 0,
fontSize: 24,
zoom: 1,
fontClass: {
alias: '站酷快乐体',
id: 543,
value: 'zcool-kuaile-regular',
url: 'https://lib.baomitu.com/fonts/zcool-kuaile/zcool-kuaile-regular.woff2',
},
fontFamily: 'SourceHanSansSC-Regular',
fontWeight: 'normal',
fontStyle: 'normal',
writingMode: 'horizontal-tb',
textDecoration: 'none',
color: '#000000ff',
textAlign: 'left',
text: '',
opacity: 1,
backgroundColor: '',
parent: '-1',
record: {
width: 0,
height: 0,
minWidth: 0,
minHeight: 0,
dir: 'horizontal',
},
}

View File

@ -17,7 +17,8 @@ import Qiniu from '@/common/methods/QiNiu'
import _config from '@/config'
import { getImage } from '@/common/methods/getImgDetail'
import wImage from '@/components/modules/widgets/wImage/wImage.vue'
import wText from '@/components/modules/widgets/wText/wText.vue'
// import wText from '@/components/modules/widgets/wText/wText.vue'
import { wTextSetting } from '@/components/modules/widgets/wText/wTextSetting'
export default () => {
navigator.clipboard
@ -47,7 +48,7 @@ export default () => {
break
} else if (item.types.toString().indexOf('text') !== -1) {
store.commit('setShowMoveable', false) // 清理掉上一次的选择
const setting = JSON.parse(JSON.stringify(wText.setting))
const setting = JSON.parse(JSON.stringify(wTextSetting))
setting.text = await navigator.clipboard.readText()
store.dispatch('addWidget', setting)
break

11
src/types/global.d.ts vendored
View File

@ -63,3 +63,14 @@ interface MouseEvent {
layerY: number
}
interface Document {
selection?: Selection
}
interface HTMLElement {
createTextRange(): {
moveToElementText(el: HTMLElement): void
select(): void
}
}

6
src/types/style.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
namespace StyleProperty {
type TextAlign = "center" | "end" | "left" | "right" | "start";
type WritingMode = Globals | "horizontal-tb" | "sideways-lr" | "sideways-rl" | "vertical-lr" | "vertical-rl";
}

View File

@ -60,7 +60,7 @@ import { useStore } from 'vuex'
import RightClickMenu from '@/components/business/right-click-menu/RcMenu.vue'
import Moveable from '@/components/business/moveable/Moveable.vue'
import shortcuts from '@/mixins/shortcuts'
import wText from '@/components/modules/widgets/wText/wText.vue'
// import wText from '@/components/modules/widgets/wText/wText.vue'
import wImage from '@/components/modules/widgets/wImage/wImage.vue'
import useLoading from '@/common/methods/loading'
import uploader from '@/components/common/Uploader/index.vue'
@ -71,6 +71,7 @@ import ProgressLoading from '@/components/common/ProgressLoading/index.vue'
// import MyWorker from '@/utils/plugins/webWorker'
import { processPSD2Page } from '@/utils/plugins/psd'
import { useSetupMapGetters } from '@/common/hooks/mapGetters'
import { wTextSetting } from '@/components/modules/widgets/wText/wTextSetting'
type TState = {
isDone: boolean
@ -124,7 +125,7 @@ async function loadPSD(file: File) {
setTimeout(async () => {
const types: any = {
text: wText.setting,
text: wTextSetting,
image: wImage.setting,
}
for (let i = 0; i < data.clouds.length; i++) {

View File

@ -3,7 +3,7 @@
* @Date: 2022-01-12 11:26:53
* @Description: 顶部操作按钮组
* @LastEditors: ShawnPhang <https://m.palxp.cn>
* @LastEditTime: 2023-12-11 12:40:59
* @LastEditTime: 2024-03-11 01:43:30
-->
<template>
<div class="top-title"><el-input v-model="state.title" placeholder="未命名的设计" class="input-wrap" /></div>
@ -27,7 +27,7 @@
<script lang="ts" setup>
import api from '@/api'
import { reactive, toRefs, defineEmits, defineProps, ref } from 'vue'
import { reactive, toRefs, ref } from 'vue'
import { mapGetters, mapActions, useStore } from 'vuex'
import { useRoute, useRouter } from 'vue-router'
import _dl from '@/common/methods/download'
@ -175,10 +175,10 @@ async function load(id: number, tempId: number, type: number, cb: () => void) {
cb()
return
}
const { data: content, title, state, width, height } = await api.home[apiName]({ id: id || tempId, type })
const { data: content, title, state: _state, width, height } = await api.home[apiName]({ id: id || tempId, type })
if (content) {
const data = JSON.parse(content)
state.stateBollean = (!!state)
state.stateBollean = !!_state
state.title = title
store.commit('setShowMoveable', false) //
// this.$store.commit('setDWidgets', [])