hookehuyr

feat(ui): RichTextRenderer 新增链接长按复制功能

- 新增 @link-copy 事件,长按链接时复制 URL 到剪贴板
- 使用 class="rich-text-link" 替代 data-is-link 属性(Taro v-html 过滤 data-*)
- 支持纯文本链接和 PDF 文件链接的长按复制
- 添加蓝色下划线样式,让链接更易识别
- 测试页新增纯文本链接测试用例

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
......@@ -7,6 +7,7 @@
* - <a> 标签自动替换为 <div data-href="">
* - 图片长按预览
* - 文件链接点击处理
* - 链接长按复制
* - 可选的 transformElement 图片自动处理
*
* @example
......@@ -15,6 +16,7 @@
* :enable-transform="true"
* @image-preview="handlePreview"
* @file-click="handleFileClick"
* @link-copy="handleLinkCopy"
* />
-->
<template>
......@@ -54,7 +56,7 @@ const props = defineProps({
/**
* 组件事件
*/
const emit = defineEmits(['image-preview', 'file-click'])
const emit = defineEmits(['image-preview', 'file-click', 'link-copy'])
// 文件操作
const { viewFile } = useFileOperation()
......@@ -83,10 +85,21 @@ const decodeHtmlEntities = (html) => {
*/
const replaceAnchorTags = (html) => {
let content = html
// 替换 <a ... href="..."> 为 <div ... data-href="..." data-is-link="true">
content = content.replace(/<a\s+/g, '<div data-is-link="true" ')
// 统计原始 a 标签数量
const anchorCount = (content.match(/<a\s+/g) || []).length
console.log('[RichTextRenderer] replaceAnchorTags: 原始 a 标签数量:', anchorCount)
// 替换 <a ... href="..."> 为 <div ... data-href="..." class="rich-text-link"
// 使用 class 而不是 data-is-link,因为 Taro v-html 可能过滤 data-* 属性
content = content.replace(/<a\s+/g, '<div class="rich-text-link" ')
content = content.replace(/href=/g, 'data-href=')
content = content.replace(/<\/a>/g, '</div>')
// 统计替换后 class 数量
const linkClassCount = (content.match(/class="rich-text-link"/g) || []).length
console.log('[RichTextRenderer] replaceAnchorTags: 替换后 rich-text-link class 数量:', linkClassCount)
return content
}
......@@ -107,6 +120,14 @@ const processContent = (raw) => {
// 2. 替换 <a> 标签
processed = replaceAnchorTags(processed)
// 调试:查找包含 "宏利官网" 的片段
if (processed.includes('宏利官网')) {
const startIdx = processed.indexOf('宏利官网') - 50
const endIdx = processed.indexOf('宏利官网') + 50
const snippet = processed.substring(startIdx, endIdx)
console.log('[RichTextRenderer] 处理后的链接片段:', snippet)
}
processedContent.value = processed
}
......@@ -136,8 +157,8 @@ const setupTransformElement = () => {
const pDataIsLink = parent.getAttribute?.('data-is-link')
const pClassList = parent.classList?.value || ''
// 检查是否在 <a> 或带有 data-is-link 或 _file_list class 的元素内
if (pName === 'a' || pDataIsLink === 'true' || pClassList.includes('_file_list')) {
// 检查是否在 <a> 或带有 data-is-link 或 rich-text-link 或 _file_list class 的元素内
if (pName === 'a' || pDataIsLink === 'true' || pClassList.includes('rich-text-link') || pClassList.includes('_file_list')) {
isInsideLink = true
break
}
......@@ -199,12 +220,17 @@ const bindImageEvents = () => {
const bindFileLinkEvents = () => {
nextTick(() => {
const container = $(CONTAINER_ID)
const fileLinks = container.find('div[data-is-link="true"]')
// 如果找不到 data-is-link,尝试用 class 查找
const targetLinks = fileLinks.length > 0 ? fileLinks : container.find('._file_list')
// 查找所有链接元素
const richTextLinks = container.find('.rich-text-link')
const fileLinks = container.find('._file_list')
// 合并所有链接
let allLinks = []
if (richTextLinks.length > 0) allLinks = allLinks.concat(richTextLinks.toArray())
if (fileLinks.length > 0) allLinks = allLinks.concat(fileLinks.toArray())
targetLinks.each(function (idx, el) {
allLinks.forEach((el) => {
const $el = $(el)
const dataHref = $el.attr('data-href')
......@@ -225,6 +251,90 @@ const bindFileLinkEvents = () => {
}
/**
* 绑定链接长按复制事件
*/
const bindLinkLongPressEvents = () => {
nextTick(() => {
const container = $(CONTAINER_ID)
console.log('[RichTextRenderer] bindLinkLongPressEvents 开始')
// 1. 查找 rich-text-link class(替换后的 a 标签)- 改用 class 因为 data-* 属性可能被过滤
const richTextLinks = container.find('.rich-text-link')
console.log('[RichTextRenderer] .rich-text-link 长度:', richTextLinks.length)
// 2. 查找原始 a 标签(可能没有被替换)
const anchorLinks = container.find('a[href]')
console.log('[RichTextRenderer] a[href] 长度:', anchorLinks.length)
// 3. 查找 _file_list class(PDF 文件链接)
const fileLinks = container.find('._file_list')
console.log('[RichTextRenderer] ._file_list 长度:', fileLinks.length)
// 合并所有链接
let allLinks = []
if (richTextLinks.length > 0) allLinks = allLinks.concat(richTextLinks.toArray())
if (anchorLinks.length > 0) allLinks = allLinks.concat(anchorLinks.toArray())
if (fileLinks.length > 0) allLinks = allLinks.concat(fileLinks.toArray())
console.log('[RichTextRenderer] 总链接数:', allLinks.length)
allLinks.forEach((el, idx) => {
const $el = $(el)
const dataHref = $el.attr('data-href') || $el.attr('href')
console.log(`[RichTextRenderer] 链接 ${idx}:`, {
tagName: el.tagName,
hasDataHref: !!$el.attr('data-href'),
hasHref: !!$el.attr('href'),
href: dataHref,
text: $el.text().trim().substring(0, 50)
})
if (dataHref) {
$el.off('longpress') // 先解绑,避免重复
$el.on('longpress', function () {
console.log('[RichTextRenderer] longpress 事件触发!dataHref:', dataHref)
// 长按复制链接
Taro.setClipboardData({
data: dataHref,
success: () => {
console.log('[RichTextRenderer] 复制成功')
// 尝试提取文件名用于提示
const fileName = $el.find('span span span').first().text() ||
$el.text().trim().substring(0, 30) ||
'链接'
Taro.showToast({
title: '链接已复制',
icon: 'success',
duration: 2000
})
// 触发父组件事件
emit('link-copy', { url: dataHref, fileName })
},
fail: (err) => {
console.error('[RichTextRenderer] 复制链接失败:', err)
Taro.showToast({
title: '复制失败',
icon: 'error'
})
}
})
})
console.log(`[RichTextRenderer] 已为链接 ${idx} 绑定 longpress 事件`)
}
})
console.log('[RichTextRenderer] bindLinkLongPressEvents 完成')
})
}
/**
* 处理内容变化
*/
const handleContentChange = () => {
......@@ -234,6 +344,7 @@ const handleContentChange = () => {
nextTick(() => {
bindImageEvents()
bindFileLinkEvents()
bindLinkLongPressEvents()
})
}
......@@ -551,7 +662,8 @@ watch(() => props.enableTransform, () => {
}
// 文件链接交互样式
div[data-is-link="true"] {
div[data-is-link="true"],
.rich-text-link {
cursor: pointer;
transition: opacity 0.2s;
......@@ -559,5 +671,12 @@ watch(() => props.enableTransform, () => {
opacity: 0.7;
}
}
// 纯文本链接样式(让链接看起来像可点击的)
.rich-text-link {
color: #1989fa;
text-decoration: underline;
display: inline;
}
}
</style>
......
......@@ -12,6 +12,7 @@
* 3. HTML 实体解析
* 4. 图片长按预览
* 5. 文件链接点击
* 6. 链接长按复制
-->
<template>
<view class="rich-text-test-page">
......@@ -26,6 +27,7 @@
<text class="test-item">2. 内容切换 {{ contentSwitchTest ? '✅' : '❌' }}</text>
<text class="test-item">3. 图片预览 {{ imagePreviewTest ? '✅' : '❌' }}</text>
<text class="test-item">4. 文件链接 {{ fileLinkTest ? '✅' : '❌' }}</text>
<text class="test-item">5. 长按复制 {{ linkCopyTest ? '✅' : '❌' }}</text>
</view>
<!-- RichTextRenderer 渲染区域 -->
......@@ -37,6 +39,7 @@
:enable-transform="transformEnabled"
@image-preview="handleImagePreview"
@file-click="handleFileClick"
@link-copy="handleLinkCopy"
/>
</view>
</view>
......@@ -69,7 +72,7 @@
<text class="note-item">• RichTextRenderer 基于 Taro v-html</text>
<text class="note-item">• 自动解析 HTML 实体(&nbsp; &amp; 等)</text>
<text class="note-item">• 自动替换 &lt;a&gt; 为 &lt;div data-href=""&gt;</text>
<text class="note-item">• 支持图片长按预览和文件链接点击</text>
<text class="note-item">• 支持图片长按预览、文件链接点击、链接长按复制</text>
</view>
</view>
</view>
......@@ -91,6 +94,7 @@ const renderTest = ref(false)
const contentSwitchTest = ref(false)
const imagePreviewTest = ref(false)
const fileLinkTest = ref(false)
const linkCopyTest = ref(false)
const switchCount = ref(0)
const currentContentType = ref('未加载')
const contentLength = ref(0)
......@@ -180,6 +184,15 @@ const realApiData = {
<p>🕹️签署合约后,每月需交给公司200元/月行政费!</p>
<hr />
<p>&nbsp;</p>
<p>🔗相关链接(长按可复制):</p>
<p>• <a href="https://www.manulife.com" target="_blank" rel="noopener">宏利官网</a> - 了解公司最新动态</p>
<p>• <a href="https://www.hkma.org.hk" target="_blank" rel="noopener">香港保险业监管局</a> - 查询保险中介人资质</p>
<p>• <a href="https://www.ia.org.hk" target="_blank" rel="noopener">保监局一站通</a> - 在线申请挂牌</p>
<p>• <a href="https://www.example.com/courses" target="_blank" rel="noopener">培训课程报名</a> - 新人课和财富管家课程</p>
<p>&nbsp;</p>
<p>💡提示:长按上方链接可以复制链接地址</p>
<hr />
<p>&nbsp;</p>
<p>入职资料Sample</p>
<p><a class="_file_list" style="display: inline-block; padding: 12px; border: 1px solid #e1e5e9; border-radius: 8px; margin: 8px; background: #f8f9fa; box-shadow: 0 2px 4px rgba(0,0,0,0.1); max-width: 400px; transition: all 0.3s ease; text-decoration: none; color: inherit; cursor: pointer; font-family: -apple-system,BlinkMacSystemFont,;" href="https://cdn.ipadbiz.cn/space_3079606/需提供的部分个人资料SAMPLE(不含流水SAMPLE)_扫描版_lkAqOTlyK-jZ7RbBN43oiG5QPfr-.pdf" target="_blank" rel="noopener"><span style="display: inline-block; font-size: 24px; margin-right: 12px; color: #6c757d; vertical-align: middle;"><img style="width: 30px; height: 30px; vertical-align: text-bottom;" src="https://cdn.ipadbiz.cn/img%2Ftinymce_icon%2Foffice-pdf.png" /></span><span style="display: inline-block; vertical-align: middle;"><span style="display: block; font-weight: 600; font-size: 14px; color: #2c3e50; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; line-height: 1.2;">需提供的部分个人资料SAMPLE(不含流水S....pdf</span><span style="display: block; font-size: 12px; color: #6c757d; margin-top: 4px; line-height: 1.2;">101.91 MB</span></span></a></p>
<p>&nbsp;</p>`
......@@ -233,6 +246,12 @@ const handleFileClick = ({ fileName }) => {
lastAction.value = `文件点击: ${fileName}`
}
// 处理链接复制事件
const handleLinkCopy = ({ url, fileName }) => {
linkCopyTest.value = true
lastAction.value = `长按复制: ${fileName}`
}
// 切换测试内容
const switchTestContent = (index) => {
const template = testContents[index]
......