Commit b36682cb authored by tyyin lan's avatar tyyin lan

feat(common component): 富文本&图片裁切

parent 496d85af
......@@ -20,12 +20,14 @@
"@tailwindcss/vite": "^4.1.8",
"@vueuse/core": "^13.3.0",
"axios": "^1.9.0",
"cropperjs": "1",
"crypto-js": "^4.2.0",
"element-plus": "^2.9.11",
"nanoid": "^5.1.5",
"nprogress": "^0.2.0",
"pinia": "^3.0.2",
"tailwindcss": "^4.1.8",
"tinymce": "^7.9.1",
"tippy.js": "^6.3.7",
"vue": "^3.5.16",
"vue-i18n": "^11.1.5",
......@@ -40,7 +42,6 @@
"@types/crypto-js": "^4.2.2",
"@types/node": "^22.15.29",
"@types/nprogress": "^0.2.3",
"@typescript-eslint/parser": "^8.33.1",
"@vitejs/plugin-vue": "^5.2.4",
"autoprefixer": "^10.4.21",
"eslint": "^9.28.0",
......
......@@ -20,6 +20,9 @@ importers:
axios:
specifier: ^1.9.0
version: 1.9.0
cropperjs:
specifier: '1'
version: 1.6.2
crypto-js:
specifier: ^4.2.0
version: 4.2.0
......@@ -38,6 +41,9 @@ importers:
tailwindcss:
specifier: ^4.1.8
version: 4.1.8
tinymce:
specifier: ^7.9.1
version: 7.9.1
tippy.js:
specifier: ^6.3.7
version: 6.3.7
......@@ -75,9 +81,6 @@ importers:
'@types/nprogress':
specifier: ^0.2.3
version: 0.2.3
'@typescript-eslint/parser':
specifier: ^8.33.1
version: 8.33.1(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3)
'@vitejs/plugin-vue':
specifier: ^5.2.4
version: 5.2.4(vite@6.3.5(@types/node@22.15.29)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.77.6)(yaml@2.8.0))(vue@3.5.16(typescript@5.8.3))
......@@ -653,56 +656,67 @@ packages:
resolution: {integrity: sha512-wC53ZNDgt0pqx5xCAgNunkTzFE8GTgdZ9EwYGVcg+jEjJdZGtq9xPjDnFgfFozQI/Xm1mh+D9YlYtl+ueswNEg==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.41.1':
resolution: {integrity: sha512-jwKCca1gbZkZLhLRtsrka5N8sFAaxrGz/7wRJ8Wwvq3jug7toO21vWlViihG85ei7uJTpzbXZRcORotE+xyrLA==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.41.1':
resolution: {integrity: sha512-g0UBcNknsmmNQ8V2d/zD2P7WWfJKU0F1nu0k5pW4rvdb+BIqMm8ToluW/eeRmxCared5dD76lS04uL4UaNgpNA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.41.1':
resolution: {integrity: sha512-XZpeGB5TKEZWzIrj7sXr+BEaSgo/ma/kCgrZgL0oo5qdB1JlTzIYQKel/RmhT6vMAvOdM2teYlAaOGJpJ9lahg==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loongarch64-gnu@4.41.1':
resolution: {integrity: sha512-bkCfDJ4qzWfFRCNt5RVV4DOw6KEgFTUZi2r2RuYhGWC8WhCA8lCAJhDeAmrM/fdiAH54m0mA0Vk2FGRPyzI+tw==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-powerpc64le-gnu@4.41.1':
resolution: {integrity: sha512-3mr3Xm+gvMX+/8EKogIZSIEF0WUu0HL9di+YWlJpO8CQBnoLAEL/roTCxuLncEdgcfJcvA4UMOf+2dnjl4Ut1A==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.41.1':
resolution: {integrity: sha512-3rwCIh6MQ1LGrvKJitQjZFuQnT2wxfU+ivhNBzmxXTXPllewOF7JR1s2vMX/tWtUYFgphygxjqMl76q4aMotGw==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.41.1':
resolution: {integrity: sha512-LdIUOb3gvfmpkgFZuccNa2uYiqtgZAz3PTzjuM5bH3nvuy9ty6RGc/Q0+HDFrHrizJGVpjnTZ1yS5TNNjFlklw==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.41.1':
resolution: {integrity: sha512-oIE6M8WC9ma6xYqjvPhzZYk6NbobIURvP/lEbh7FWplcMO6gn7MM2yHKA1eC/GvYwzNKK/1LYgqzdkZ8YFxR8g==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.41.1':
resolution: {integrity: sha512-cWBOvayNvA+SyeQMp79BHPK8ws6sHSsYnK5zDcsC3Hsxr1dgTABKjMnMslPq1DvZIp6uO7kIWhiGwaTdR4Og9A==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.41.1':
resolution: {integrity: sha512-y5CbN44M+pUCdGDlZFzGGBSKCA4A/J2ZH4edTYSSxFg7ce1Xt3GtydbVKWLlzL+INfFIZAEg1ZV6hh9+QQf9YQ==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-win32-arm64-msvc@4.41.1':
resolution: {integrity: sha512-lZkCxIrjlJlMt1dLO/FbpZbzt6J/A8p4DnqzSa4PWqPEUUUnzXLeki/iyPLfV0BmHItlYgHUqJe+3KiyydmiNQ==}
......@@ -760,24 +774,28 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.1.8':
resolution: {integrity: sha512-O6b8QesPbJCRshsNApsOIpzKt3ztG35gfX9tEf4arD7mwNinsoCKxkj8TgEE0YRjmjtO3r9FlJnT/ENd9EVefQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.1.8':
resolution: {integrity: sha512-32iEXX/pXwikshNOGnERAFwFSfiltmijMIAbUhnNyjFr3tmWmMJWQKU2vNcFX0DACSXJ3ZWcSkzNbaKTdngH6g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.1.8':
resolution: {integrity: sha512-s+VSSD+TfZeMEsCaFaHTaY5YNj3Dri8rST09gMvYQKwPphacRG7wbuQ5ZJMIJXN/puxPcg/nU+ucvWguPpvBDg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.1.8':
resolution: {integrity: sha512-CXBPVFkpDjM67sS1psWohZ6g/2/cd+cq56vPxK4JeawelxwK4YECgl9Y9TjkE2qfF+9/s1tHHJqrC4SS6cVvSg==}
......@@ -1218,6 +1236,9 @@ packages:
typescript:
optional: true
cropperjs@1.6.2:
resolution: {integrity: sha512-nhymn9GdnV3CqiEHJVai54TULFAE3VshJTXSqSJKa8yXAKyBKDWdhHarnlIPrshJ0WMFTGuFvG02YjLXfPiuOA==}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
......@@ -1834,24 +1855,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.30.1:
resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.30.1:
resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.30.1:
resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.30.1:
resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==}
......@@ -2530,6 +2555,9 @@ packages:
resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==}
engines: {node: '>=12.0.0'}
tinymce@7.9.1:
resolution: {integrity: sha512-zaOHwmiP1EqTeLRXAvVriDb00JYnfEjWGPdKEuac7MiZJ5aiDMZ4Unc98Gmajn+PBljOmO1GKV6G0KwWn3+k8A==}
tippy.js@6.3.7:
resolution: {integrity: sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==}
......@@ -3853,6 +3881,8 @@ snapshots:
optionalDependencies:
typescript: 5.8.3
cropperjs@1.6.2: {}
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
......@@ -5066,6 +5096,8 @@ snapshots:
fdir: 6.4.5(picomatch@4.0.2)
picomatch: 4.0.2
tinymce@7.9.1: {}
tippy.js@6.3.7:
dependencies:
'@popperjs/core': 2.11.8
......
<script setup lang="ts">
import Cropper from 'cropperjs'
import 'cropperjs/dist/cropper.css'
import { nextTick, ref, shallowRef, useTemplateRef } from 'vue'
const imageElRef = useTemplateRef('imageElRef')
// const rectanglePreview = useTemplateRef('rectanglePreview')
// const roundPreview = useTemplateRef('roundPreview')
const dialogVisible = ref(false)
const cropperIns = shallowRef<Cropper | null>(null)
const isInitCropper = ref(false)
const confirmBtnLoading = ref(false)
const currentEditImageUrl = ref('')
let cropImageResolve: (value: any) => void = () => {}
let cropImageReject: (value: any) => void = () => {}
function initCropper(options: Cropper.Options<HTMLImageElement>) {
return new Promise((resolve) => {
nextTick(() => {
if (imageElRef.value) {
cropperIns.value = new Cropper(imageElRef.value, {
viewMode: 2,
dragMode: 'move',
aspectRatio: 1.25,
// cropBoxMovable: false, // 可通过拖动移动裁剪框
// cropBoxResizable: false, // 可通过拖动调整裁剪框的大小
minCropBoxWidth: 50, // 裁剪框的最小宽度
minCropBoxHeight: 50, // 裁剪框的最小高度
// autoCropArea: 1,
// preview: previewRef.value! || [],
// preview: [roundPreview.value!],
// rotatable: false, // 旋转
// scalable: false, // 可伸缩
// zoomable: false, // 可缩放
// zoomOnTouch: false, // 缩放触摸
ready: () => {
if (!isInitCropper.value) {
isInitCropper.value = true
resolve('加载成功')
}
},
...options,
})
}
})
})
}
function handleCropConfirm() {
if (cropperIns.value) {
confirmBtnLoading.value = true
cropperIns.value.getCroppedCanvas().toBlob((blob) => {
if (blob) {
const fd = new FormData()
const file = new File([blob], `image.${blob.type.split('/')[1]}`, { type: blob.type })
fd.append('file', file)
cropImageResolve(file)
confirmBtnLoading.value = false
dialogVisible.value = false
}
})
}
}
function handleCropCancel() {
dialogVisible.value = false
cropImageReject(new Error('Cancel'))
}
function cropImage(url: string, options: Cropper.Options<HTMLImageElement> = {}): Promise<File> {
currentEditImageUrl.value = url
dialogVisible.value = true
return initCropper(options).then(() => {
return new Promise((resolve, reject) => {
cropImageResolve = resolve
cropImageReject = reject
})
})
}
function onModalAfterLeave() {
if (cropperIns.value) {
cropperIns.value.destroy()
cropperIns.value = null
isInitCropper.value = false
}
}
defineExpose({
cropImage,
})
</script>
<template>
<el-dialog v-model="dialogVisible" title="图片裁切" width="800" @closed="onModalAfterLeave">
<div class="relative h-[400px]">
<Transition>
<div v-show="!isInitCropper" class="absolute inset-0 z-1">
<el-skeleton animated loading class="h-full">
<template #template>
<el-skeleton-item variant="image" class="!h-full" />
</template>
</el-skeleton>
</div>
</Transition>
<img
v-show="isInitCropper"
ref="imageElRef"
class="block h-full w-full"
alt="Picture"
:src="currentEditImageUrl"
/>
</div>
<template #footer>
<el-button plain @click="handleCropCancel">取消</el-button>
<el-button type="primary" plain :laoding="confirmBtnLoading" @click="handleCropConfirm">裁切</el-button>
</template>
</el-dialog>
</template>
<style lang="css" scoped>
.v-enter-active,
.v-leave-active {
transition: opacity 0.5s ease;
}
.v-enter-from,
.v-leave-to {
opacity: 0;
}
</style>
/* Import TinyMCE */
import tinymce from 'tinymce'
import type { RawEditorOptions } from 'tinymce'
/* icons */
import 'tinymce/icons/default/icons.min.js'
/* 主题 */
import 'tinymce/themes/silver/theme.min.js'
import 'tinymce/models/dom/model.min.js'
/* 皮肤 */
import 'tinymce/skins/ui/oxide/skin.js'
import 'tinymce/skins/ui/oxide/content.js'
import 'tinymce/skins/ui/oxide/skin.shadowdom.js'
import 'tinymce/skins/content/default/content.js'
/* 插件 */
import 'tinymce/plugins/advlist'
import 'tinymce/plugins/code'
import 'tinymce/plugins/emoticons'
import 'tinymce/plugins/emoticons/js/emojis'
import 'tinymce/plugins/link'
import 'tinymce/plugins/lists'
import 'tinymce/plugins/table'
/* 多语言 */
import './lib/langs/zh_CN.js'
export function editorRender(options: RawEditorOptions) {
tinymce.init({
selector: '#tinymce-app textarea#tinymce-textarea',
license_key: 'gpl',
language: 'zh_CN',
placeholder: '请输入你的内容...',
menubar: false,
height: '100%',
statusbar: false,
// branding: false,
// resize: false,
automatic_uploads: false,
browser_spellcheck: true,
// highlight_on_focus: false,
plugins: 'advlist code emoticons link lists table',
toolbar:
'undo redo | accordion accordionremove | blocks fontsize | bold italic underline strikethrough | align numlist bullist | link image | table media | lineheight outdent indent | forecolor backcolor removeformat',
toolbar_mode: 'sliding',
skin_url: 'default',
content_css: 'default',
/* 覆盖默认配置 */
...options,
})
}
This diff is collapsed.
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { editorRender } from './editor-loader'
import type { Editor, RawEditorOptions } from 'tinymce'
interface Props {
initOptions?: RawEditorOptions
}
const { initOptions = {} } = defineProps<Props>()
const isEditorRender = ref(false)
const editorInstance = ref<Editor | null>(null)
function editorLoader() {
editorRender({
...initOptions,
setup: (editor) => {
if (initOptions.setup) initOptions.setup(editor)
editorInstance.value = editor
editor.on('init', () => {
isEditorRender.value = true
})
},
})
}
onMounted(() => {
editorLoader()
})
function getContent() {
return editorInstance.value?.getContent() || ''
}
defineExpose({
getContent,
})
</script>
<template>
<div id="tinymce-app" v-loading="!isEditorRender" class="relative h-[400px] w-full">
<textarea id="tinymce-textarea" :class="isEditorRender ? 'opacity-0' : 'opacity-100'"></textarea>
</div>
</template>
......@@ -27,6 +27,23 @@ export default defineConfig(({ command, mode }) => {
server: {
host: true,
port: envConf.VITE_PORT,
/* proxy: {
'/local': {
target: 'http://127.0.0.1:3000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/local/, '/api/rest'),
},
}, */
},
build: {
rollupOptions: {
output: {
manualChunks: {
tinymce: ['tinymce'],
},
},
},
},
}
})
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment