424 lines
10 KiB
Vue
424 lines
10 KiB
Vue
<template>
|
|
<div :class="className" class="screen">
|
|
<div class="flex contenteditable align-center space-between">
|
|
<p
|
|
:id="'editor' + id"
|
|
ref="editor"
|
|
:contenteditable="active"
|
|
class="van-field contenteditable-content"
|
|
:data-placeholder="placeholder"
|
|
@focus="onFocus"
|
|
@input="onChange($event.target, $event)"
|
|
v-html="modelValue"
|
|
></p>
|
|
<!-- <p-->
|
|
<!-- v-else-->
|
|
<!-- ref="editor"-->
|
|
<!-- class="van-field contenteditable-content"-->
|
|
<!-- v-html="modelValue"-->
|
|
<!-- :placeholder="placeholder"-->
|
|
<!-- ></p>-->
|
|
<div class="right-icon ml10">
|
|
<slot name="right-icon"></slot>
|
|
</div>
|
|
</div>
|
|
<teleport to="body">
|
|
<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>
|
|
</teleport>
|
|
|
|
<div class="error-message">
|
|
{{ errorMessage }}
|
|
<!-- <slot name="error"></slot>-->
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { defineEmits, ref, onMounted, watch, onBeforeUnmount } from 'vue';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import CommonApi from '@/api/common.js';
|
|
|
|
defineProps({
|
|
className: {
|
|
type: String,
|
|
default: ''
|
|
},
|
|
placeholder: {
|
|
type: String,
|
|
default: '请输入'
|
|
}
|
|
});
|
|
const modelValue = defineModel('modelValue', {
|
|
type: String,
|
|
default: ''
|
|
});
|
|
|
|
const errorMessage = defineModel('errorMessage', {
|
|
type: String,
|
|
default: ''
|
|
});
|
|
const active = defineModel('active', {
|
|
type: Boolean,
|
|
default: false
|
|
});
|
|
|
|
const id = ref(uuidv4());
|
|
|
|
const editor = ref(null);
|
|
const editorAction = ref(null);
|
|
const emit = defineEmits(['update:modelValue', 'blur', 'change']);
|
|
const save = (e) => {
|
|
emit('update:modelValue', e.innerHTML);
|
|
emit('blur', 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;
|
|
// eslint-disable-next-line standard/no-callback-literal
|
|
// const style = `style="width:${width}px;
|
|
// height:${height}px;
|
|
// max-width:${maxWidth}px;
|
|
// max-height:${maxHeight}px"`;
|
|
// 使用原始高度
|
|
const minimum = 50;
|
|
let height = img.naturalHeight;
|
|
const scale = img.naturalHeight / 50;
|
|
if (height > minimum * 3) {
|
|
height = height / scale;
|
|
} else {
|
|
height = minimum;
|
|
}
|
|
// 根据宽高比计算宽度
|
|
const width = (height * 3) / 4;
|
|
const maxWidth = img.naturalWidth;
|
|
const maxHeight = img.naturalHeight;
|
|
// eslint-disable-next-line standard/no-callback-literal
|
|
const style = `style="width:${width}px;
|
|
height:${height}px;
|
|
max-width:${maxWidth}px;
|
|
max-height:${maxHeight}px"`;
|
|
// eslint-disable-next-line standard/no-callback-literal
|
|
insertImageAtCaret(`<img src=${data.url} ${style}/>`);
|
|
|
|
// 恢复光标位置
|
|
restoreSelection(savedRange.value);
|
|
};
|
|
};
|
|
}
|
|
};
|
|
|
|
const funEvent = (item) => {
|
|
functions[item.fun]();
|
|
};
|
|
|
|
watch(
|
|
() => active.value,
|
|
(newVal) => {
|
|
if (!newVal) {
|
|
save(editor.value);
|
|
setTimeout(() => {
|
|
showAction.value = false;
|
|
}, 100);
|
|
}
|
|
},
|
|
{
|
|
deep: true
|
|
}
|
|
);
|
|
|
|
watch(
|
|
() => modelValue.value,
|
|
(newVal) => {
|
|
if (newVal) {
|
|
setTimeout(() => {
|
|
onChange(editor.value);
|
|
});
|
|
}
|
|
}
|
|
);
|
|
|
|
const isEmptyContent = (html) => {
|
|
// 移除所有空白字符
|
|
const trimmedHtml = html.trim();
|
|
// 检查是否为空字符串、仅包含 <br> 或仅包含 <p><br></p>
|
|
return (
|
|
trimmedHtml === '' ||
|
|
trimmedHtml === '<br>' ||
|
|
trimmedHtml === '<p><br></p>' ||
|
|
trimmedHtml === '<p></p>'
|
|
);
|
|
};
|
|
|
|
const onChange = (target) => {
|
|
if (isEmptyContent(target.innerHTML)) {
|
|
editor.value.classList.add('editor-placeholder');
|
|
// 删除br
|
|
editor.value.innerHTML = editor.value.innerHTML.replace(/<br>/g, '');
|
|
} else {
|
|
editor.value.classList.remove('editor-placeholder');
|
|
}
|
|
};
|
|
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;
|
|
}
|
|
};
|
|
|
|
const isIOS = ref(/iPad|iPhone|iPod/.test(navigator.userAgent));
|
|
const keyboardVisible = ref(false);
|
|
|
|
// Handle iOS keyboard events
|
|
const handleVisualViewportResize = () => {
|
|
if (!isIOS.value) return;
|
|
|
|
const viewport = window.visualViewport;
|
|
if (!viewport) return;
|
|
|
|
// If the viewport height is significantly reduced, the keyboard is likely visible
|
|
const keyboardShown = viewport.height < window.innerHeight * 0.8;
|
|
keyboardVisible.value = keyboardShown;
|
|
|
|
if (keyboardShown && editorAction.value) {
|
|
// Position the editor action bar above the keyboard
|
|
editorAction.value.style.bottom = `${window.innerHeight - viewport.height}px`;
|
|
} else if (editorAction.value) {
|
|
// Reset position when keyboard is hidden
|
|
editorAction.value.style.bottom = 'env(safe-area-inset-bottom, 0)';
|
|
}
|
|
};
|
|
|
|
onMounted(() => {
|
|
onChange(editor.value);
|
|
editor.value.addEventListener('focus', () => {
|
|
showAction.value = true;
|
|
|
|
// iOS specific handling for keyboard
|
|
if (isIOS.value) {
|
|
// Add a small delay to ensure the keyboard is fully shown
|
|
setTimeout(() => {
|
|
if (editorAction.value) {
|
|
// On iOS, adjust position when keyboard appears
|
|
window.scrollTo(0, 0);
|
|
document.body.scrollTop = 0;
|
|
}
|
|
}, 300);
|
|
}
|
|
});
|
|
|
|
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;
|
|
});
|
|
|
|
// Set up visual viewport event listener for iOS
|
|
if (window.visualViewport) {
|
|
window.visualViewport.addEventListener('resize', handleVisualViewportResize);
|
|
}
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
// Clean up event listeners
|
|
if (window.visualViewport) {
|
|
window.visualViewport.removeEventListener('resize', handleVisualViewportResize);
|
|
}
|
|
});
|
|
|
|
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>
|
|
.screen {
|
|
width: 100%;
|
|
}
|
|
.contenteditable-content {
|
|
width: 100%;
|
|
height: 100%;
|
|
img {
|
|
vertical-align: text-top;
|
|
}
|
|
& p {
|
|
display: block;
|
|
vertical-align: top;
|
|
}
|
|
}
|
|
</style>
|
|
<style scoped lang="scss">
|
|
.contenteditable-content {
|
|
width: 100%;
|
|
height: 100%;
|
|
min-height: 25px;
|
|
& p {
|
|
display: flex;
|
|
flex-direction: row;
|
|
flex-wrap: wrap;
|
|
}
|
|
}
|
|
|
|
.right-icon {
|
|
color: #c4c9d4;
|
|
}
|
|
.error-message {
|
|
color: red;
|
|
flex: none;
|
|
font-size: 12px;
|
|
}
|
|
.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;
|
|
/* iOS specific fixes */
|
|
-webkit-transform: translateZ(0);
|
|
transform: translateZ(0);
|
|
/* Ensure it works with iOS keyboard */
|
|
bottom: env(safe-area-inset-bottom, 0);
|
|
/* Add box shadow for better visibility */
|
|
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.05);
|
|
|
|
& button {
|
|
border: none;
|
|
background: #fff;
|
|
color: #000;
|
|
outline: none;
|
|
/* Improve touch target size for iOS */
|
|
min-width: 44px;
|
|
min-height: 40px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
button + button {
|
|
margin-left: 10px;
|
|
}
|
|
}
|
|
</style>
|