Files
ylst-h5/src/components/contenteditable.vue
2025-03-25 18:48:01 +08:00

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>