251 lines
5.3 KiB
Vue
251 lines
5.3 KiB
Vue
<template>
|
|
<div class="flex contenteditable align-center space-between" :class="className">
|
|
<p
|
|
:id="'editor' + id" ref="editor" :contenteditable="active" class="van-field contenteditable-content"
|
|
@focus="onFocus" v-html="modelValue"
|
|
></p>
|
|
<div class="right-icon ml10">
|
|
<slot name="right-icon"></slot>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="showAction && active" ref="editorAction" class="editor-action">
|
|
<button v-for="item in actions" :key="item.name" @click="funEvent(item, $event)">
|
|
<van-icon class-prefix="mobilefont" :name="item.icon"></van-icon>
|
|
</button>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { defineEmits, ref, onMounted, watch } from 'vue';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import CommonApi from '@/api/common.js';
|
|
|
|
const props = defineProps({
|
|
modelValue: {
|
|
type: String,
|
|
default: ''
|
|
},
|
|
className: {
|
|
type: String,
|
|
default: ''
|
|
},
|
|
active: {
|
|
type: Boolean,
|
|
default: false
|
|
}
|
|
});
|
|
|
|
const id = ref(uuidv4());
|
|
|
|
const editor = ref(null);
|
|
const editorAction = ref(null);
|
|
const emit = defineEmits(['update:modelValue', 'blur']);
|
|
const save = (e) => {
|
|
emit('update:modelValue', e.innerHTML);
|
|
};
|
|
|
|
const savedRange = ref(null);
|
|
|
|
const functions = {
|
|
boldModern: () => {
|
|
document.execCommand('bold', true, null);
|
|
},
|
|
underLine: () => {
|
|
document.execCommand('underline', false, null);
|
|
},
|
|
italic: () => {
|
|
document.execCommand('italic', false, null);
|
|
},
|
|
|
|
uploadImage: async() => {
|
|
// 保存当前光标位置
|
|
savedRange.value = saveSelection();
|
|
|
|
const fileInput = document.createElement('input');
|
|
fileInput.type = 'file';
|
|
|
|
fileInput.click();
|
|
|
|
fileInput.onchange = async(e) => {
|
|
const [file] = e.target.files;
|
|
if (!file) return;
|
|
if (file.size > 2 * 1024 * 1024) {
|
|
// console.error('文件大小不能超过2M');
|
|
return;
|
|
}
|
|
|
|
const data = await CommonApi.cosUpload(file);
|
|
const img = document.createElement('img');
|
|
img.src = data.url;
|
|
img.onload = () => {
|
|
const scale = img.naturalHeight / 50;
|
|
const width = (img.naturalWidth / scale).toFixed(0);
|
|
const height = 50;
|
|
const maxWidth = img.naturalWidth;
|
|
const maxHeight = img.naturalHeight;
|
|
const style = `style="width:${width}px;height:${height}px;max-width:${maxWidth}px;max-height:${maxHeight}px"`;
|
|
insertImageAtCaret(`<img src=${data.url} ${style}/>`);
|
|
|
|
// 恢复光标位置
|
|
restoreSelection(savedRange.value);
|
|
};
|
|
};
|
|
}
|
|
};
|
|
|
|
const funEvent = (item) => {
|
|
functions[item.fun]();
|
|
};
|
|
|
|
watch(
|
|
() => props.active,
|
|
(newVal) => {
|
|
if (newVal) {
|
|
// showAction.value = true;
|
|
} else {
|
|
save(editor.value);
|
|
setTimeout(() => {
|
|
showAction.value = false;
|
|
}, 100);
|
|
}
|
|
}
|
|
);
|
|
|
|
const showAction = ref(false);
|
|
const actions = [
|
|
{
|
|
label: '加粗',
|
|
fun: 'boldModern',
|
|
icon: 'jiacu'
|
|
},
|
|
{
|
|
label: '下划线',
|
|
fun: 'underLine',
|
|
icon: 'xiahuaxian'
|
|
},
|
|
{
|
|
label: '图片上传',
|
|
fun: 'uploadImage',
|
|
icon: 'tupian'
|
|
},
|
|
{
|
|
label: '倾斜',
|
|
fun: 'italic',
|
|
icon: 'qingxie'
|
|
}
|
|
];
|
|
const checkContains = (element, target) => {
|
|
try {
|
|
return element?.contains(target) ?? false;
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
onMounted(() => {
|
|
editor.value.addEventListener('focus', () => {
|
|
showAction.value = true;
|
|
});
|
|
|
|
document.addEventListener('click', (e) => {
|
|
if (!editor.value || !editorAction.value) return;
|
|
|
|
const target = e.composedPath?.()?.[0] || e.target;
|
|
const isEditor = checkContains(editor.value, target);
|
|
const isActionBar = checkContains(editorAction.value, target);
|
|
|
|
if (!isEditor && !isActionBar) {
|
|
showAction.value = false;
|
|
save(editor.value);
|
|
emit('blur', editor.value);
|
|
}
|
|
});
|
|
|
|
document.addEventListener('resize', () => {
|
|
showAction.value = false;
|
|
});
|
|
});
|
|
|
|
const onFocus = (e) => {
|
|
// 阻止
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
// console.log('Editor focused');
|
|
};
|
|
|
|
// 保存当前光标位置
|
|
const saveSelection = () => {
|
|
const selection = window.getSelection();
|
|
if (selection.rangeCount === 0) return null;
|
|
|
|
return selection.getRangeAt(0);
|
|
};
|
|
|
|
// 恢复光标位置
|
|
const restoreSelection = (range) => {
|
|
if (!range) return;
|
|
|
|
const selection = window.getSelection();
|
|
selection.removeAllRanges();
|
|
selection.addRange(range);
|
|
};
|
|
|
|
// 在光标位置插入图片
|
|
const insertImageAtCaret = (html) => {
|
|
const selection = window.getSelection();
|
|
if (selection.rangeCount === 0) return;
|
|
|
|
const range = selection.getRangeAt(0);
|
|
range.deleteContents();
|
|
|
|
const div = document.createElement('div');
|
|
div.innerHTML = html;
|
|
const frag = document.createDocumentFragment();
|
|
for (let child = div.firstChild; child; child = div.firstChild) {
|
|
frag.appendChild(child);
|
|
}
|
|
|
|
range.insertNode(frag);
|
|
range.setStartAfter(frag);
|
|
range.collapse(true);
|
|
|
|
selection.removeAllRanges();
|
|
selection.addRange(range);
|
|
};
|
|
</script>
|
|
|
|
<style scoped lang="scss">
|
|
.contenteditable-content {
|
|
width: 100%;
|
|
}
|
|
|
|
.right-icon {
|
|
color: #c4c9d4;
|
|
}
|
|
|
|
.editor-action {
|
|
position: fixed;
|
|
bottom: 0;
|
|
left: 0;
|
|
z-index: 2008;
|
|
display: flex;
|
|
width: 100%;
|
|
height: 40px;
|
|
padding: 0 10px;
|
|
background: #fff;
|
|
line-height: 40px;
|
|
|
|
& button {
|
|
border: none;
|
|
background: #fff;
|
|
color: #000;
|
|
outline: none;
|
|
}
|
|
|
|
button+button {
|
|
margin-left: 10px;
|
|
}
|
|
}
|
|
</style>
|