1476 lines
49 KiB
Vue
1476 lines
49 KiB
Vue
<template>
|
||
<div class="document-search-word">
|
||
<div class="viewer-container">
|
||
<transition name="slide-fade">
|
||
<div v-if="showSearchBar" class="floating-search">
|
||
<div class="search-toolbar" :class="{ 'is-searching': searching }">
|
||
<div class="search-box">
|
||
<el-input v-model="keyword" class="search-input" placeholder="输入关键字搜索" clearable
|
||
ref="keywordInput" @keyup.enter.native="handleSearch" @clear="resetSearch">
|
||
</el-input>
|
||
<button class="icon-btn search-icon" :disabled="searching" @click="handleSearch">
|
||
<i class="el-icon-search" v-if="!searching"></i>
|
||
<i class="el-icon-loading" v-else></i>
|
||
</button>
|
||
</div>
|
||
<div class="search-status">
|
||
<span class="result-indicator">{{ resultDisplay }}</span>
|
||
<button class="icon-btn" :disabled="!searchResults.length" @click="goToPrevious">
|
||
<i class="el-icon-arrow-up"></i>
|
||
</button>
|
||
<button class="icon-btn" :disabled="!searchResults.length" @click="goToNext">
|
||
<i class="el-icon-arrow-down"></i>
|
||
</button>
|
||
<button class="icon-btn" @click="handleCloseSearch">
|
||
<i class="el-icon-close"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</transition>
|
||
<button v-if="!showSearchBar" class="floating-search-btn" @click="toggleSearchBar">
|
||
<i class="el-icon-search"></i>
|
||
</button>
|
||
<div ref="docWrapper" class="doc-wrapper">
|
||
<div ref="docxContainer" class="doc-content"></div>
|
||
</div>
|
||
|
||
<transition name="fade">
|
||
<div v-if="loading" key="loading" class="overlay state-panel">
|
||
<i class="el-icon-loading state-icon"></i>
|
||
<p>正在加载 Word 文档,请稍候...</p>
|
||
</div>
|
||
<div v-else-if="error" key="error" class="overlay state-panel">
|
||
<i class="el-icon-warning-outline state-icon"></i>
|
||
<p>{{ error }}</p>
|
||
<el-button type="primary" @click="reload">重新加载</el-button>
|
||
</div>
|
||
<div v-else-if="!docUrl" key="no-url" class="overlay state-panel">
|
||
<i class="el-icon-document state-icon"></i>
|
||
<p>暂未指定 Word 文件,请通过路由参数 url 传入文件地址。</p>
|
||
</div>
|
||
</transition>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script>
|
||
import * as docxPreview from 'docx-preview/dist/docx-preview.js'
|
||
|
||
const DEFAULT_DOC_URL = 'http://192.168.0.14:9090/smart-bid/technicalSolutionDatabase/2025/11/11/887b35d28b2149b6a7555fb639be9411.docx'
|
||
|
||
export default {
|
||
name: 'DocumentSearchWord',
|
||
props: {
|
||
fileUrl: {
|
||
type: String,
|
||
default: ''
|
||
}
|
||
},
|
||
computed: {
|
||
resultDisplay() {
|
||
const total = this.searchResults.length
|
||
if (!total) return '0/0'
|
||
const current = this.currentResultIndex >= 0 ? this.currentResultIndex + 1 : 0
|
||
return `${current}/${total}`
|
||
}
|
||
},
|
||
data() {
|
||
return {
|
||
docUrl: '',
|
||
keyword: '',
|
||
loading: false,
|
||
error: null,
|
||
searchResults: [],
|
||
currentResultIndex: -1,
|
||
searching: false,
|
||
searchSegments: [],
|
||
docRendered: false,
|
||
abortController: null,
|
||
scrollAnimationFrame: null,
|
||
showSearchBar: false,
|
||
}
|
||
},
|
||
mounted() {
|
||
// 优先使用 fileUrl prop,然后是路由参数,最后是默认 URL
|
||
const initialUrl = this.fileUrl || this.$route?.query?.url || DEFAULT_DOC_URL
|
||
if (initialUrl && initialUrl !== this.docUrl) {
|
||
this.applyDocUrl(initialUrl)
|
||
} else if (this.docUrl && !this.docRendered) {
|
||
// 如果 docUrl 已设置但文档未渲染,重新加载
|
||
this.loadDocument()
|
||
}
|
||
},
|
||
beforeDestroy() {
|
||
this.cancelFetch()
|
||
this.cancelScrollAnimation()
|
||
},
|
||
watch: {
|
||
fileUrl: {
|
||
immediate: true,
|
||
handler(newVal, oldVal) {
|
||
// 当 fileUrl 变化时,总是重新加载文档
|
||
if (newVal && newVal !== oldVal) {
|
||
this.applyDocUrl(newVal)
|
||
} else if (newVal && !this.docUrl) {
|
||
// 如果新值存在但 docUrl 为空,也加载
|
||
this.applyDocUrl(newVal)
|
||
} else if (!newVal && this.docUrl) {
|
||
// 如果新值为空但 docUrl 有值,重置状态
|
||
this.resetViewerState()
|
||
}
|
||
}
|
||
},
|
||
'$route.query.url'(newUrl, oldUrl) {
|
||
if (this.fileUrl) return
|
||
if (newUrl !== oldUrl) {
|
||
const target = newUrl || DEFAULT_DOC_URL
|
||
this.applyDocUrl(target)
|
||
}
|
||
},
|
||
},
|
||
methods: {
|
||
toggleSearchBar() {
|
||
this.showSearchBar = true
|
||
this.$nextTick(() => {
|
||
if (this.$refs.keywordInput) {
|
||
this.$refs.keywordInput.focus()
|
||
}
|
||
})
|
||
},
|
||
handleCloseSearch() {
|
||
this.keyword = ''
|
||
this.resetSearch()
|
||
this.showSearchBar = false
|
||
},
|
||
applyDocUrl(url) {
|
||
const resolved = url || ''
|
||
// 如果 URL 相同且文档已渲染,不重复加载
|
||
if (resolved === this.docUrl && this.docRendered) {
|
||
return
|
||
}
|
||
// 如果 URL 变化,先取消之前的请求和重置状态
|
||
if (resolved !== this.docUrl) {
|
||
this.cancelFetch()
|
||
this.cancelScrollAnimation()
|
||
this.resetViewerState()
|
||
}
|
||
this.docUrl = resolved
|
||
if (this.docUrl) {
|
||
this.loadDocument()
|
||
} else {
|
||
this.resetViewerState()
|
||
}
|
||
},
|
||
cancelFetch() {
|
||
if (this.abortController) {
|
||
this.abortController.abort()
|
||
this.abortController = null
|
||
}
|
||
},
|
||
|
||
async loadDocument() {
|
||
if (!this.docUrl) return
|
||
this.loading = true
|
||
this.error = null
|
||
this.docRendered = false
|
||
this.clearHighlights()
|
||
this.searchSegments = []
|
||
this.cancelFetch()
|
||
const container = this.$refs.docxContainer
|
||
if (container) {
|
||
container.innerHTML = ''
|
||
}
|
||
|
||
try {
|
||
const headers = {}
|
||
const token = this.$store?.getters?.token || window?.sessionStorage?.getItem('token')
|
||
if (token) {
|
||
headers.Authorization = token.startsWith('Bearer ') ? token : `Bearer ${token}`
|
||
}
|
||
this.abortController = new AbortController()
|
||
const response = await fetch(this.docUrl, {
|
||
headers,
|
||
credentials: 'include',
|
||
signal: this.abortController.signal,
|
||
})
|
||
if (!response.ok) {
|
||
throw new Error(`加载 Word 文档失败:${response.statusText}`)
|
||
}
|
||
const arrayBuffer = await response.arrayBuffer()
|
||
if (!container) {
|
||
throw new Error('未找到文档容器')
|
||
}
|
||
await docxPreview.renderAsync(arrayBuffer, container, null, {
|
||
className: 'docx-viewer',
|
||
inWrapper: true,
|
||
hideWrapperOnPrint: false,
|
||
ignoreWidth: false,
|
||
ignoreHeight: false,
|
||
ignoreFonts: false,
|
||
breakPages: false,
|
||
ignoreLastRenderedPageBreak: true,
|
||
experimental: false,
|
||
trimXmlDeclaration: true,
|
||
useBase64URL: false,
|
||
renderChanges: false,
|
||
renderHeaders: true,
|
||
renderFooters: true,
|
||
renderFootnotes: true,
|
||
renderEndnotes: true,
|
||
renderComments: false,
|
||
renderAltChunks: true,
|
||
useMathMLPolyfill: false,
|
||
debug: false,
|
||
})
|
||
this.normalizeTableStyles(container)
|
||
this.docRendered = true
|
||
this.loading = false
|
||
this.$nextTick(() => {
|
||
this.prepareSearchSegments()
|
||
if (this.keyword.trim()) {
|
||
this.highlightMatches()
|
||
}
|
||
})
|
||
} catch (error) {
|
||
if (error.name === 'AbortError') return
|
||
console.error('Word 文档预览失败:', error)
|
||
this.loading = false
|
||
this.error = error?.message || 'Word 文档加载失败,请稍后重试'
|
||
this.docRendered = false
|
||
} finally {
|
||
this.abortController = null
|
||
}
|
||
},
|
||
|
||
getElementTextContent(element) {
|
||
if (!element) return ''
|
||
return (element.textContent || '').trim()
|
||
},
|
||
|
||
getSearchableTextContent(element) {
|
||
if (!element) return ''
|
||
let text = ''
|
||
const walker = document.createTreeWalker(
|
||
element,
|
||
NodeFilter.SHOW_TEXT,
|
||
{
|
||
acceptNode: (node) => {
|
||
const parent = node.parentElement
|
||
if (!parent) return NodeFilter.FILTER_REJECT
|
||
const tagName = parent.tagName ? parent.tagName.toLowerCase() : ''
|
||
if (tagName === 'mark' || tagName === 'script' || tagName === 'style' || tagName === 'noscript') {
|
||
return NodeFilter.FILTER_REJECT
|
||
}
|
||
return NodeFilter.FILTER_ACCEPT
|
||
}
|
||
}
|
||
)
|
||
let node
|
||
while ((node = walker.nextNode())) {
|
||
if (node.textContent) {
|
||
text += node.textContent
|
||
}
|
||
}
|
||
return text
|
||
},
|
||
|
||
prepareSearchSegments(forceReset = false) {
|
||
if (!this.docRendered) {
|
||
if (forceReset) {
|
||
this.searchSegments = []
|
||
}
|
||
return
|
||
}
|
||
const container = this.$refs.docxContainer
|
||
if (!container) return
|
||
|
||
const selectors = [
|
||
'.docx-viewer p',
|
||
'.docx-viewer li',
|
||
'.docx-viewer td',
|
||
'.docx-viewer th',
|
||
'.docx-viewer h1',
|
||
'.docx-viewer h2',
|
||
'.docx-viewer h3',
|
||
'.docx-viewer h4',
|
||
'.docx-viewer h5',
|
||
'.docx-viewer h6',
|
||
'.docx-viewer span',
|
||
]
|
||
|
||
const allElements = Array.from(container.querySelectorAll(selectors.join(',')))
|
||
const targetTags = ['p', 'li', 'td', 'th', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'span']
|
||
|
||
const elements = allElements.filter((el) => {
|
||
const text = this.getElementTextContent(el)
|
||
if (text.length === 0) return false
|
||
|
||
let parent = el.parentElement
|
||
while (parent && parent !== container) {
|
||
const parentTag = parent.tagName ? parent.tagName.toLowerCase() : ''
|
||
if (targetTags.includes(parentTag)) {
|
||
return false
|
||
}
|
||
parent = parent.parentElement
|
||
}
|
||
return true
|
||
})
|
||
|
||
elements.forEach((el, idx) => {
|
||
if (typeof el.dataset.originalHtml === 'undefined') {
|
||
const cleanHtml = this.cleanHtmlFromMarks(el.innerHTML)
|
||
el.dataset.originalHtml = cleanHtml
|
||
} else {
|
||
el.dataset.originalHtml = this.cleanHtmlFromMarks(el.dataset.originalHtml)
|
||
}
|
||
if (typeof el.dataset.originalText === 'undefined') {
|
||
el.dataset.originalText = this.getElementTextContent(el)
|
||
}
|
||
el.dataset.segmentIndex = idx
|
||
})
|
||
|
||
this.searchSegments = elements
|
||
},
|
||
|
||
async handleSearch() {
|
||
const keyword = this.keyword.trim()
|
||
if (!keyword) {
|
||
this.resetSearch()
|
||
return
|
||
}
|
||
if (!this.docRendered) {
|
||
this.prepareSearchSegments()
|
||
}
|
||
// 立即设置 searching 状态,确保动画能够显示
|
||
this.searching = true
|
||
// 使用 requestAnimationFrame 确保 DOM 更新后再执行搜索
|
||
await this.$nextTick()
|
||
await new Promise(resolve => requestAnimationFrame(() => {
|
||
requestAnimationFrame(resolve)
|
||
}))
|
||
try {
|
||
// 执行搜索,确保有足够时间显示动画
|
||
const searchPromise = Promise.resolve(this.highlightMatches())
|
||
const minDelayPromise = new Promise(resolve => setTimeout(resolve, 300))
|
||
await Promise.all([searchPromise, minDelayPromise])
|
||
} finally {
|
||
this.searching = false
|
||
}
|
||
},
|
||
|
||
cleanHtmlFromMarks(html) {
|
||
if (!html) return html
|
||
const tempDiv = document.createElement('div')
|
||
tempDiv.innerHTML = html
|
||
const marks = tempDiv.querySelectorAll('mark')
|
||
marks.forEach(mark => {
|
||
const parent = mark.parentNode
|
||
while (mark.firstChild) {
|
||
parent.insertBefore(mark.firstChild, mark)
|
||
}
|
||
parent.removeChild(mark)
|
||
})
|
||
return tempDiv.innerHTML
|
||
},
|
||
|
||
collectTextNodes(element, textNodes) {
|
||
if (!element) return
|
||
const walker = document.createTreeWalker(
|
||
element,
|
||
NodeFilter.SHOW_TEXT,
|
||
{
|
||
acceptNode: (node) => {
|
||
const parent = node.parentElement
|
||
if (!parent) return NodeFilter.FILTER_REJECT
|
||
const tagName = parent.tagName ? parent.tagName.toLowerCase() : ''
|
||
if (tagName === 'mark' || tagName === 'script' || tagName === 'style' || tagName === 'noscript') {
|
||
return NodeFilter.FILTER_REJECT
|
||
}
|
||
return NodeFilter.FILTER_ACCEPT
|
||
}
|
||
}
|
||
)
|
||
let node
|
||
while ((node = walker.nextNode())) {
|
||
if (node.textContent) {
|
||
textNodes.push(node)
|
||
}
|
||
}
|
||
},
|
||
|
||
highlightMatches() {
|
||
const keyword = this.keyword.trim()
|
||
if (!keyword) {
|
||
this.resetSearch()
|
||
return
|
||
}
|
||
if (!this.searchSegments.length) {
|
||
this.prepareSearchSegments()
|
||
}
|
||
|
||
const escapedKeyword = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||
const pattern = new RegExp(`(${escapedKeyword})`, 'gi')
|
||
|
||
this.clearHighlights()
|
||
|
||
const results = []
|
||
let totalTextMatches = 0
|
||
|
||
this.searchSegments.forEach((el, segmentIndex) => {
|
||
const originalHtml = el.dataset.originalHtml
|
||
if (typeof originalHtml === 'undefined') return
|
||
|
||
const cleanHtml = this.cleanHtmlFromMarks(originalHtml)
|
||
el.innerHTML = cleanHtml
|
||
el.dataset.originalHtml = cleanHtml
|
||
|
||
const searchableText = this.getSearchableTextContent(el)
|
||
if (!searchableText) return
|
||
|
||
pattern.lastIndex = 0
|
||
const textMatchCount = (searchableText.match(pattern) || []).length
|
||
if (textMatchCount === 0) return
|
||
|
||
totalTextMatches += textMatchCount
|
||
|
||
const existingMarks = el.querySelectorAll('mark')
|
||
if (existingMarks.length > 0) {
|
||
console.warn(`Segment ${segmentIndex} still has ${existingMarks.length} mark tags after cleaning`)
|
||
}
|
||
|
||
const textNodes = []
|
||
this.collectTextNodes(el, textNodes)
|
||
|
||
if (textNodes.length === 0) return
|
||
|
||
const markIndexRef = { value: 0 }
|
||
|
||
const nodeMap = []
|
||
let fullText = ''
|
||
let currentOffset = 0
|
||
|
||
textNodes.forEach((textNode, nodeIndex) => {
|
||
const text = textNode.textContent || ''
|
||
const startOffset = currentOffset
|
||
const endOffset = currentOffset + text.length
|
||
|
||
nodeMap.push({
|
||
textNode,
|
||
text,
|
||
startOffset,
|
||
endOffset,
|
||
nodeIndex
|
||
})
|
||
|
||
fullText += text
|
||
currentOffset = endOffset
|
||
})
|
||
|
||
if (!fullText) return
|
||
|
||
pattern.lastIndex = 0
|
||
const allMatches = []
|
||
let match
|
||
while ((match = pattern.exec(fullText)) !== null) {
|
||
allMatches.push({
|
||
start: match.index,
|
||
end: match.index + match[0].length,
|
||
text: match[0]
|
||
})
|
||
}
|
||
|
||
if (allMatches.length === 0) return
|
||
|
||
const nodeReplacements = new Map()
|
||
const processedMatches = new Set()
|
||
|
||
allMatches.forEach((matchInfo, matchIndex) => {
|
||
const matchStart = matchInfo.start
|
||
const matchEnd = matchInfo.end
|
||
const matchKey = `${matchStart}-${matchEnd}`
|
||
|
||
if (processedMatches.has(matchKey)) {
|
||
return
|
||
}
|
||
processedMatches.add(matchKey)
|
||
|
||
const affectedNodes = []
|
||
for (let i = 0; i < nodeMap.length; i++) {
|
||
const nodeInfo = nodeMap[i]
|
||
if (nodeInfo.endOffset > matchStart && nodeInfo.startOffset < matchEnd) {
|
||
affectedNodes.push({
|
||
...nodeInfo,
|
||
matchStartInNode: Math.max(0, matchStart - nodeInfo.startOffset),
|
||
matchEndInNode: Math.min(nodeInfo.text.length, matchEnd - nodeInfo.startOffset)
|
||
})
|
||
}
|
||
}
|
||
|
||
if (affectedNodes.length === 0) return
|
||
|
||
affectedNodes.forEach((nodeInfo, idx) => {
|
||
if (!nodeReplacements.has(nodeInfo.nodeIndex)) {
|
||
nodeReplacements.set(nodeInfo.nodeIndex, {
|
||
textNode: nodeInfo.textNode,
|
||
ranges: [],
|
||
matchIndices: new Set()
|
||
})
|
||
}
|
||
|
||
const replacement = nodeReplacements.get(nodeInfo.nodeIndex)
|
||
const isFirst = idx === 0
|
||
const isLast = idx === affectedNodes.length - 1
|
||
|
||
if (isFirst && isLast) {
|
||
const rangeKey = `${nodeInfo.matchStartInNode}-${nodeInfo.matchEndInNode}-${matchIndex}`
|
||
if (!replacement.matchIndices.has(rangeKey)) {
|
||
replacement.ranges.push({
|
||
start: nodeInfo.matchStartInNode,
|
||
end: nodeInfo.matchEndInNode,
|
||
isFullMatch: true,
|
||
matchIndex: matchIndex,
|
||
shouldAddToResults: true
|
||
})
|
||
replacement.matchIndices.add(rangeKey)
|
||
}
|
||
} else if (isFirst) {
|
||
const rangeKey = `${nodeInfo.matchStartInNode}-${nodeInfo.text.length}-${matchIndex}`
|
||
if (!replacement.matchIndices.has(rangeKey)) {
|
||
replacement.ranges.push({
|
||
start: nodeInfo.matchStartInNode,
|
||
end: nodeInfo.text.length,
|
||
isStart: true,
|
||
matchIndex: matchIndex,
|
||
shouldAddToResults: true
|
||
})
|
||
replacement.matchIndices.add(rangeKey)
|
||
}
|
||
} else if (isLast) {
|
||
const rangeKey = `0-${nodeInfo.matchEndInNode}-${matchIndex}`
|
||
if (!replacement.matchIndices.has(rangeKey)) {
|
||
replacement.ranges.push({
|
||
start: 0,
|
||
end: nodeInfo.matchEndInNode,
|
||
isEnd: true,
|
||
matchIndex: matchIndex,
|
||
shouldAddToResults: false
|
||
})
|
||
replacement.matchIndices.add(rangeKey)
|
||
}
|
||
} else {
|
||
const rangeKey = `0-${nodeInfo.text.length}-${matchIndex}`
|
||
if (!replacement.matchIndices.has(rangeKey)) {
|
||
replacement.ranges.push({
|
||
start: 0,
|
||
end: nodeInfo.text.length,
|
||
isMiddle: true,
|
||
matchIndex: matchIndex,
|
||
shouldAddToResults: false
|
||
})
|
||
replacement.matchIndices.add(rangeKey)
|
||
}
|
||
}
|
||
})
|
||
})
|
||
|
||
for (let i = textNodes.length - 1; i >= 0; i--) {
|
||
const textNode = textNodes[i]
|
||
const parent = textNode.parentNode
|
||
if (!parent || !document.body.contains(textNode)) continue
|
||
|
||
if (nodeReplacements.has(i)) {
|
||
const replacement = nodeReplacements.get(i)
|
||
const originalText = replacement.textNode.textContent
|
||
let ranges = replacement.ranges
|
||
|
||
ranges.sort((a, b) => {
|
||
if (a.start !== b.start) return a.start - b.start
|
||
return a.end - b.end
|
||
})
|
||
|
||
const mergedRanges = []
|
||
ranges.forEach(range => {
|
||
if (mergedRanges.length === 0) {
|
||
mergedRanges.push({ ...range })
|
||
} else {
|
||
const lastRange = mergedRanges[mergedRanges.length - 1]
|
||
if (range.start <= lastRange.end) {
|
||
lastRange.end = Math.max(lastRange.end, range.end)
|
||
} else {
|
||
mergedRanges.push({ ...range })
|
||
}
|
||
}
|
||
})
|
||
|
||
const fragment = document.createDocumentFragment()
|
||
let lastIndex = 0
|
||
|
||
mergedRanges.forEach(range => {
|
||
if (lastIndex < range.start) {
|
||
const beforeText = originalText.substring(lastIndex, range.start)
|
||
if (beforeText) {
|
||
fragment.appendChild(document.createTextNode(beforeText))
|
||
}
|
||
}
|
||
|
||
const mark = document.createElement('mark')
|
||
mark.className = 'search-highlight'
|
||
mark.textContent = originalText.substring(range.start, range.end)
|
||
fragment.appendChild(mark)
|
||
|
||
if (range.shouldAddToResults !== false) {
|
||
results.push({
|
||
element: mark,
|
||
segmentIndex: segmentIndex,
|
||
markIndex: markIndexRef.value,
|
||
parentElement: el
|
||
})
|
||
}
|
||
|
||
markIndexRef.value++
|
||
|
||
lastIndex = range.end
|
||
})
|
||
|
||
if (lastIndex < originalText.length) {
|
||
const afterText = originalText.substring(lastIndex)
|
||
if (afterText) {
|
||
fragment.appendChild(document.createTextNode(afterText))
|
||
}
|
||
}
|
||
|
||
if (fragment.childNodes.length > 0) {
|
||
try {
|
||
parent.replaceChild(fragment, textNode)
|
||
} catch (e) {
|
||
console.warn(`Failed to replace text node in segment ${segmentIndex}:`, e)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
})
|
||
|
||
if (results.length !== totalTextMatches) {
|
||
console.warn(`Total matches mismatch: expected ${totalTextMatches}, got ${results.length}`)
|
||
}
|
||
|
||
this.searchResults = results
|
||
|
||
if (!results.length) {
|
||
this.currentResultIndex = -1
|
||
this.$message && this.$message.info(`未找到"${keyword}"相关内容`)
|
||
return
|
||
}
|
||
|
||
this.currentResultIndex = 0
|
||
this.updateActiveHighlight()
|
||
this.$nextTick(() => {
|
||
this.navigateToResult(this.currentResultIndex, false)
|
||
})
|
||
},
|
||
|
||
getResultElement(result) {
|
||
if (!result) return null
|
||
|
||
if (result.element && document.body.contains(result.element)) {
|
||
return result.element
|
||
}
|
||
|
||
if (result.parentElement && result.segmentIndex !== undefined && result.markIndex !== undefined) {
|
||
const parent = result.parentElement
|
||
if (document.body.contains(parent)) {
|
||
const marks = parent.querySelectorAll('mark.search-highlight')
|
||
if (marks[result.markIndex]) {
|
||
result.element = marks[result.markIndex]
|
||
return result.element
|
||
}
|
||
}
|
||
}
|
||
|
||
if (result.segmentIndex !== undefined && result.markIndex !== undefined && this.searchSegments[result.segmentIndex]) {
|
||
const segment = this.searchSegments[result.segmentIndex]
|
||
if (document.body.contains(segment)) {
|
||
const marks = segment.querySelectorAll('mark.search-highlight')
|
||
if (marks[result.markIndex]) {
|
||
result.element = marks[result.markIndex]
|
||
result.parentElement = segment
|
||
return result.element
|
||
}
|
||
}
|
||
}
|
||
|
||
return null
|
||
},
|
||
|
||
updateActiveHighlight() {
|
||
this.searchResults.forEach((item, idx) => {
|
||
const element = this.getResultElement(item)
|
||
if (!element) return
|
||
|
||
if (idx === this.currentResultIndex) {
|
||
element.classList.add('is-active', 'is-current')
|
||
} else {
|
||
element.classList.remove('is-active', 'is-current')
|
||
}
|
||
})
|
||
},
|
||
|
||
calculateOffsetTop(element, container) {
|
||
let offset = 0
|
||
let node = element
|
||
while (node && node !== container) {
|
||
offset += node.offsetTop || 0
|
||
node = node.offsetParent
|
||
}
|
||
return offset
|
||
},
|
||
|
||
cancelScrollAnimation() {
|
||
if (this.scrollAnimationFrame) {
|
||
cancelAnimationFrame(this.scrollAnimationFrame)
|
||
this.scrollAnimationFrame = null
|
||
}
|
||
},
|
||
|
||
smoothScrollTo(container, target, baseDuration = 350) {
|
||
if (!container) return
|
||
const maxScroll = container.scrollHeight - container.clientHeight
|
||
const finalTarget = Math.min(Math.max(target, 0), Math.max(maxScroll, 0))
|
||
const start = container.scrollTop
|
||
const change = finalTarget - start
|
||
if (Math.abs(change) < 1) {
|
||
container.scrollTop = finalTarget
|
||
return
|
||
}
|
||
|
||
const distance = Math.abs(change)
|
||
const viewportHeight = container.clientHeight
|
||
const distanceFactor = Math.min(distance / Math.max(viewportHeight, 1), 2.2)
|
||
const duration = Math.min(600, baseDuration + distanceFactor * 90)
|
||
|
||
const startTime = performance.now()
|
||
this.cancelScrollAnimation()
|
||
|
||
const easeOutExpo = (t) => {
|
||
return t === 1 ? 1 : 1 - Math.pow(2, -10 * t)
|
||
}
|
||
|
||
const step = (now) => {
|
||
const elapsed = now - startTime
|
||
const progress = Math.min(elapsed / duration, 1)
|
||
const eased = easeOutExpo(progress)
|
||
const currentPosition = start + change * eased
|
||
|
||
container.scrollTop = Math.min(Math.max(currentPosition, 0), maxScroll)
|
||
|
||
if (progress < 1) {
|
||
this.scrollAnimationFrame = requestAnimationFrame(step)
|
||
} else {
|
||
container.scrollTop = finalTarget
|
||
this.scrollAnimationFrame = null
|
||
}
|
||
}
|
||
|
||
this.scrollAnimationFrame = requestAnimationFrame(step)
|
||
},
|
||
|
||
navigateToResult(index, useSmoothScroll = true) {
|
||
if (!this.searchResults.length || index < 0 || index >= this.searchResults.length) return
|
||
this.currentResultIndex = index
|
||
this.updateActiveHighlight()
|
||
|
||
this.$nextTick(() => {
|
||
requestAnimationFrame(() => {
|
||
requestAnimationFrame(() => {
|
||
const result = this.searchResults[this.currentResultIndex]
|
||
const target = this.getResultElement(result)
|
||
const wrapper = this.$refs.docWrapper
|
||
if (!target || !wrapper) return
|
||
|
||
if (!document.body.contains(target)) {
|
||
return
|
||
}
|
||
|
||
const targetRect = target.getBoundingClientRect()
|
||
if (targetRect.width === 0 && targetRect.height === 0) {
|
||
return
|
||
}
|
||
|
||
const wrapperRect = wrapper.getBoundingClientRect()
|
||
const margin = 24
|
||
|
||
const isInViewport = targetRect.top >= wrapperRect.top + margin &&
|
||
targetRect.bottom <= wrapperRect.bottom - margin &&
|
||
targetRect.left >= wrapperRect.left &&
|
||
targetRect.right <= wrapperRect.right
|
||
|
||
if (isInViewport) {
|
||
return
|
||
}
|
||
|
||
const performScroll = () => {
|
||
try {
|
||
const currentScrollTop = wrapper.scrollTop
|
||
const targetTopRelativeToWrapper = targetRect.top - wrapperRect.top + currentScrollTop
|
||
const desiredScrollTop = Math.max(targetTopRelativeToWrapper - margin, 0)
|
||
|
||
if (useSmoothScroll) {
|
||
this.smoothScrollTo(wrapper, desiredScrollTop)
|
||
} else {
|
||
this.cancelScrollAnimation()
|
||
wrapper.scrollTop = desiredScrollTop
|
||
}
|
||
} catch (error) {
|
||
try {
|
||
let absoluteTop = 0
|
||
let node = target
|
||
while (node && node !== wrapper) {
|
||
absoluteTop += node.offsetTop || 0
|
||
node = node.offsetParent
|
||
}
|
||
const desiredScrollTop = Math.max(absoluteTop - margin, 0)
|
||
|
||
if (useSmoothScroll) {
|
||
this.smoothScrollTo(wrapper, desiredScrollTop)
|
||
} else {
|
||
this.cancelScrollAnimation()
|
||
wrapper.scrollTop = desiredScrollTop
|
||
}
|
||
} catch (e) {
|
||
console.error('Scroll error:', e)
|
||
}
|
||
}
|
||
}
|
||
|
||
performScroll()
|
||
})
|
||
})
|
||
})
|
||
},
|
||
|
||
async goToPrevious() {
|
||
if (!this.searchResults.length) return
|
||
let targetIndex = this.currentResultIndex
|
||
if (targetIndex === -1) {
|
||
targetIndex = this.searchResults.length - 1
|
||
} else {
|
||
targetIndex -= 1
|
||
if (targetIndex < 0) {
|
||
targetIndex = this.searchResults.length - 1
|
||
}
|
||
}
|
||
this.navigateToResult(targetIndex)
|
||
},
|
||
|
||
async goToNext() {
|
||
if (!this.searchResults.length) return
|
||
let targetIndex = this.currentResultIndex
|
||
if (targetIndex === -1) {
|
||
targetIndex = 0
|
||
} else {
|
||
targetIndex += 1
|
||
if (targetIndex > this.searchResults.length - 1) {
|
||
targetIndex = 0
|
||
}
|
||
}
|
||
this.navigateToResult(targetIndex)
|
||
},
|
||
|
||
clearHighlights() {
|
||
if (!this.searchSegments.length) return
|
||
this.searchSegments.forEach((el) => {
|
||
if (typeof el.dataset.originalHtml !== 'undefined') {
|
||
el.innerHTML = el.dataset.originalHtml
|
||
}
|
||
})
|
||
},
|
||
|
||
resetSearch() {
|
||
this.clearHighlights()
|
||
this.searchResults = []
|
||
this.currentResultIndex = -1
|
||
},
|
||
|
||
resetViewerState() {
|
||
this.clearHighlights()
|
||
this.searchResults = []
|
||
this.currentResultIndex = -1
|
||
this.searchSegments = []
|
||
this.docRendered = false
|
||
this.loading = !!this.docUrl
|
||
this.error = null
|
||
const container = this.$refs.docxContainer
|
||
if (container) {
|
||
container.innerHTML = ''
|
||
}
|
||
},
|
||
|
||
reload() {
|
||
if (!this.docUrl) return
|
||
this.loadDocument()
|
||
},
|
||
|
||
normalizeTableStyles(container) {
|
||
if (!container) return
|
||
const tables = container.querySelectorAll('table')
|
||
tables.forEach((table) => {
|
||
table.style.width = 'auto'
|
||
table.style.tableLayout = 'auto'
|
||
table.style.textAlign = 'left'
|
||
table.style.margin = '0 auto'
|
||
const pTags = table.querySelectorAll('td p.docx-viewer_tableparagraph')
|
||
pTags.forEach((p) => {
|
||
p.style.lineHeight = '1.2'
|
||
})
|
||
})
|
||
}
|
||
},
|
||
}
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.document-search-word {
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: calc(100vh - 84px);
|
||
background: #f8f9fa;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.document-search-word article.docx-article-wrapper {
|
||
display: block;
|
||
margin: 0;
|
||
background: transparent;
|
||
border-radius: 0;
|
||
box-shadow: none;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
color: #303133;
|
||
line-height: 1.8;
|
||
font-size: 15px;
|
||
// 启用文本选择和复制
|
||
user-select: text;
|
||
-webkit-user-select: text;
|
||
-moz-user-select: text;
|
||
-ms-user-select: text;
|
||
cursor: text;
|
||
}
|
||
|
||
.floating-search {
|
||
position: absolute;
|
||
top: 20px;
|
||
right: 20px;
|
||
width: auto;
|
||
max-width: calc(100% - 40px);
|
||
z-index: 6;
|
||
pointer-events: none;
|
||
|
||
@media (max-width: 768px) {
|
||
top: 12px;
|
||
right: 12px;
|
||
max-width: calc(100% - 24px);
|
||
}
|
||
}
|
||
|
||
.floating-search-btn {
|
||
position: absolute;
|
||
top: 20px;
|
||
right: 20px;
|
||
width: 44px;
|
||
height: 44px;
|
||
border-radius: 50%;
|
||
border: none;
|
||
background: #2b68ff;
|
||
color: #fff;
|
||
font-size: 18px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
box-shadow: 0 4px 12px rgba(43, 104, 255, 0.3);
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
z-index: 5;
|
||
|
||
@media (max-width: 768px) {
|
||
top: 12px;
|
||
right: 12px;
|
||
width: 40px;
|
||
height: 40px;
|
||
font-size: 16px;
|
||
}
|
||
}
|
||
|
||
.floating-search-btn:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 6px 16px rgba(43, 104, 255, 0.4);
|
||
background: #1d4fd8;
|
||
}
|
||
|
||
.floating-search-btn:active {
|
||
transform: translateY(0);
|
||
box-shadow: 0 2px 8px rgba(43, 104, 255, 0.3);
|
||
}
|
||
|
||
.search-toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 14px;
|
||
background: rgba(255, 255, 255, 0.98);
|
||
border-radius: 999px;
|
||
padding: 8px 12px;
|
||
box-shadow: 0 8px 26px rgba(31, 114, 234, 0.18);
|
||
border: 1px solid rgba(226, 231, 239, 0.8);
|
||
pointer-events: auto;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.search-toolbar::after {
|
||
content: '';
|
||
position: absolute;
|
||
inset: 4px;
|
||
border-radius: 999px;
|
||
border: 1px solid transparent;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.search-toolbar.is-searching {
|
||
&::after {
|
||
border-color: rgba(43, 104, 255, 0.4);
|
||
animation: search-pulse 1.2s ease-in-out infinite;
|
||
}
|
||
|
||
// 确保搜索图标在搜索时显示加载动画
|
||
.search-icon {
|
||
i.el-icon-loading {
|
||
display: inline-block !important;
|
||
animation: rotating 1s linear infinite !important;
|
||
}
|
||
}
|
||
}
|
||
|
||
@keyframes search-pulse {
|
||
0% {
|
||
opacity: 0.25;
|
||
transform: scale(0.98);
|
||
}
|
||
50% {
|
||
opacity: 1;
|
||
transform: scale(1);
|
||
}
|
||
100% {
|
||
opacity: 0.25;
|
||
transform: scale(0.98);
|
||
}
|
||
}
|
||
|
||
.slide-fade-enter-active,
|
||
.slide-fade-leave-active {
|
||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||
}
|
||
|
||
.slide-fade-enter,
|
||
.slide-fade-leave-to {
|
||
opacity: 0;
|
||
transform: translateY(-6px);
|
||
}
|
||
|
||
.search-box {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
background: #fff;
|
||
border: 1px solid #e4e7f0;
|
||
border-radius: 999px;
|
||
padding: 0 6px 0 10px;
|
||
}
|
||
|
||
.search-input {
|
||
width: 190px;
|
||
}
|
||
|
||
.search-input ::v-deep .el-input__inner {
|
||
border: none;
|
||
box-shadow: none;
|
||
background: transparent;
|
||
padding: 0;
|
||
height: 30px;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.search-input ::v-deep .el-input__suffix,
|
||
.search-input ::v-deep .el-input__prefix {
|
||
display: none;
|
||
}
|
||
|
||
.icon-btn {
|
||
width: 28px;
|
||
height: 28px;
|
||
border: none;
|
||
background: transparent;
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #6c7388;
|
||
cursor: pointer;
|
||
transition: background 0.2s ease, color 0.2s ease;
|
||
}
|
||
|
||
.icon-btn:hover {
|
||
background: rgba(64, 158, 255, 0.12);
|
||
color: #2b68ff;
|
||
}
|
||
|
||
.icon-btn:disabled {
|
||
opacity: 0.35;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.search-icon {
|
||
background: #2b68ff;
|
||
color: #fff;
|
||
|
||
// 确保加载图标有旋转动画
|
||
i.el-icon-loading {
|
||
display: inline-block;
|
||
animation: rotating 1s linear infinite;
|
||
font-size: 16px;
|
||
}
|
||
|
||
i.el-icon-search {
|
||
font-size: 16px;
|
||
}
|
||
}
|
||
|
||
.search-icon:hover {
|
||
background: #1d4fd8;
|
||
color: #fff;
|
||
}
|
||
|
||
.search-icon:disabled {
|
||
opacity: 0.8;
|
||
cursor: wait;
|
||
}
|
||
|
||
@keyframes rotating {
|
||
from {
|
||
transform: rotate(0deg);
|
||
}
|
||
to {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
|
||
.search-status {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.result-indicator {
|
||
font-size: 12px;
|
||
color: #4f5875;
|
||
padding-left: 10px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
|
||
.viewer-container {
|
||
flex: 1;
|
||
background: #ffffff;
|
||
overflow: hidden;
|
||
position: relative;
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 0;
|
||
max-height: 100%;
|
||
height: 0;
|
||
}
|
||
|
||
.doc-wrapper {
|
||
flex: 1;
|
||
padding: 40px 20px;
|
||
background: #f5f7fa;
|
||
position: relative;
|
||
width: 100%;
|
||
height: 100%;
|
||
overflow-y: auto;
|
||
overflow-x: hidden;
|
||
min-height: 0;
|
||
scroll-behavior: smooth;
|
||
-webkit-overflow-scrolling: touch;
|
||
will-change: scroll-position;
|
||
// 启用文本选择
|
||
user-select: text;
|
||
-webkit-user-select: text;
|
||
-moz-user-select: text;
|
||
-ms-user-select: text;
|
||
|
||
// 自定义滚动条
|
||
&::-webkit-scrollbar {
|
||
width: 8px;
|
||
}
|
||
|
||
&::-webkit-scrollbar-track {
|
||
background: transparent;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
&::-webkit-scrollbar-thumb {
|
||
background: rgba(0, 0, 0, 0.2);
|
||
border-radius: 4px;
|
||
transition: background 0.3s;
|
||
|
||
&:hover {
|
||
background: rgba(0, 0, 0, 0.3);
|
||
}
|
||
}
|
||
}
|
||
|
||
.doc-content {
|
||
max-width: 900px;
|
||
margin: 0 auto;
|
||
background: #ffffff;
|
||
border-radius: 8px;
|
||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06);
|
||
padding: 60px 80px;
|
||
min-height: calc(100vh - 200px);
|
||
// 启用文本选择和复制
|
||
user-select: text;
|
||
-webkit-user-select: text;
|
||
-moz-user-select: text;
|
||
-ms-user-select: text;
|
||
// 优化选中文本的样式
|
||
::selection {
|
||
background: rgba(64, 158, 255, 0.2);
|
||
color: #303133;
|
||
}
|
||
::-moz-selection {
|
||
background: rgba(64, 158, 255, 0.2);
|
||
color: #303133;
|
||
}
|
||
|
||
@media (max-width: 1200px) {
|
||
padding: 50px 60px;
|
||
max-width: 95%;
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
padding: 40px 30px;
|
||
max-width: 100%;
|
||
border-radius: 0;
|
||
}
|
||
}
|
||
|
||
::v-deep .docx-wrapper {
|
||
margin: 0;
|
||
background: transparent;
|
||
border-radius: 0;
|
||
box-shadow: none;
|
||
overflow: visible;
|
||
border: none;
|
||
// 启用文本选择
|
||
user-select: text;
|
||
-webkit-user-select: text;
|
||
-moz-user-select: text;
|
||
-ms-user-select: text;
|
||
}
|
||
|
||
::v-deep .docx-viewer-wrapper {
|
||
background: transparent;
|
||
// 启用文本选择
|
||
user-select: text;
|
||
-webkit-user-select: text;
|
||
-moz-user-select: text;
|
||
-ms-user-select: text;
|
||
}
|
||
|
||
::v-deep .docx-viewer-wrapper>section.docx-viewer {
|
||
background: transparent;
|
||
box-shadow: none;
|
||
margin-bottom: 0 !important;
|
||
padding: 0;
|
||
// 启用文本选择
|
||
user-select: text;
|
||
-webkit-user-select: text;
|
||
-moz-user-select: text;
|
||
-ms-user-select: text;
|
||
}
|
||
|
||
::v-deep .docx-viewer {
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||
color: #303133;
|
||
line-height: 1.8;
|
||
// 启用文本选择和复制
|
||
user-select: text !important;
|
||
-webkit-user-select: text !important;
|
||
-moz-user-select: text !important;
|
||
-ms-user-select: text !important;
|
||
// 优化选中文本的样式
|
||
::selection {
|
||
background: rgba(64, 158, 255, 0.25) !important;
|
||
color: #303133 !important;
|
||
}
|
||
::-moz-selection {
|
||
background: rgba(64, 158, 255, 0.25) !important;
|
||
color: #303133 !important;
|
||
}
|
||
|
||
// 确保所有子元素都可以选择文本
|
||
* {
|
||
user-select: text !important;
|
||
-webkit-user-select: text !important;
|
||
-moz-user-select: text !important;
|
||
-ms-user-select: text !important;
|
||
}
|
||
|
||
p {
|
||
margin: 0.8em 0;
|
||
word-wrap: break-word;
|
||
word-break: break-word;
|
||
cursor: text;
|
||
}
|
||
|
||
h1, h2, h3, h4, h5, h6 {
|
||
margin: 1.2em 0 0.8em 0;
|
||
font-weight: 600;
|
||
line-height: 1.4;
|
||
cursor: text;
|
||
}
|
||
|
||
table {
|
||
border-collapse: collapse;
|
||
width: 100%;
|
||
margin: 1em 0;
|
||
cursor: text;
|
||
|
||
td, th {
|
||
padding: 8px 12px;
|
||
border: 1px solid #e4e7ed;
|
||
cursor: text;
|
||
}
|
||
|
||
th {
|
||
background: #f5f7fa;
|
||
font-weight: 600;
|
||
}
|
||
}
|
||
|
||
ul, ol {
|
||
margin: 0.8em 0;
|
||
padding-left: 2em;
|
||
cursor: text;
|
||
}
|
||
|
||
li {
|
||
margin: 0.4em 0;
|
||
cursor: text;
|
||
}
|
||
|
||
img {
|
||
max-width: 100%;
|
||
height: auto;
|
||
display: block;
|
||
margin: 1em auto;
|
||
user-select: none;
|
||
-webkit-user-select: none;
|
||
cursor: default;
|
||
}
|
||
|
||
// 确保链接可以选择文本
|
||
a {
|
||
cursor: pointer;
|
||
user-select: text !important;
|
||
}
|
||
|
||
// 确保代码块可以选择文本
|
||
code, pre {
|
||
user-select: text !important;
|
||
cursor: text;
|
||
}
|
||
}
|
||
|
||
|
||
.overlay {
|
||
position: absolute;
|
||
inset: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: rgba(255, 255, 255, 0.95);
|
||
backdrop-filter: blur(4px);
|
||
z-index: 10;
|
||
text-align: center;
|
||
}
|
||
|
||
.state-panel {
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
color: #606266;
|
||
|
||
p {
|
||
margin: 0;
|
||
font-size: 14px;
|
||
color: #909399;
|
||
}
|
||
}
|
||
|
||
.state-icon {
|
||
font-size: 24px;
|
||
color: #409EFF;
|
||
animation: rotate 1s linear infinite;
|
||
}
|
||
|
||
@keyframes rotate {
|
||
from {
|
||
transform: rotate(0deg);
|
||
}
|
||
to {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
|
||
.fade-enter-active,
|
||
.fade-leave-active {
|
||
transition: opacity 0.25s ease;
|
||
}
|
||
|
||
.fade-enter,
|
||
.fade-leave-to {
|
||
opacity: 0;
|
||
}
|
||
|
||
::v-deep .search-highlight {
|
||
background: rgba(255, 241, 168, 0.85);
|
||
padding: 2px 4px;
|
||
border-radius: 3px;
|
||
transition: all 0.2s ease;
|
||
// 确保高亮标记内的文本可以选择
|
||
user-select: text !important;
|
||
-webkit-user-select: text !important;
|
||
-moz-user-select: text !important;
|
||
-ms-user-select: text !important;
|
||
cursor: text;
|
||
}
|
||
|
||
::v-deep .search-highlight.is-active,
|
||
::v-deep .search-highlight.is-current {
|
||
background: linear-gradient(135deg, #409EFF 0%, #1d4fd8 100%) !important;
|
||
color: #ffffff;
|
||
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.4);
|
||
border-radius: 4px;
|
||
padding: 2px 4px;
|
||
font-weight: 500;
|
||
transition: all 0.2s ease;
|
||
// 确保激活的高亮标记内的文本可以选择
|
||
user-select: text !important;
|
||
-webkit-user-select: text !important;
|
||
-moz-user-select: text !important;
|
||
-ms-user-select: text !important;
|
||
cursor: text;
|
||
}
|
||
</style> |