162 lines
3.8 KiB
JavaScript
162 lines
3.8 KiB
JavaScript
|
|
export const HIDDEN_TEXT_NODE_TYPE = 'hiddenText'
|
||
|
|
|
||
|
|
function escapeHtml(value = '') {
|
||
|
|
return String(value)
|
||
|
|
.replace(/&/g, '&')
|
||
|
|
.replace(/</g, '<')
|
||
|
|
.replace(/>/g, '>')
|
||
|
|
.replace(/"/g, '"')
|
||
|
|
.replace(/'/g, ''')
|
||
|
|
}
|
||
|
|
|
||
|
|
export function normalizeHiddenTextValue(value = '') {
|
||
|
|
return String(value ?? '').replace(/\r\n?/g, ' ')
|
||
|
|
}
|
||
|
|
|
||
|
|
export function escapeHiddenTextSegment(value = '', closingChar) {
|
||
|
|
const normalized = normalizeHiddenTextValue(value)
|
||
|
|
const closingCharPattern = new RegExp(`\\${closingChar}`, 'g')
|
||
|
|
return normalized
|
||
|
|
.replace(/\\/g, '\\\\')
|
||
|
|
.replace(closingCharPattern, `\\${closingChar}`)
|
||
|
|
}
|
||
|
|
|
||
|
|
export function serializeHiddenTextSyntax(displayed = '', hidden = '') {
|
||
|
|
const safeDisplayed = escapeHiddenTextSegment(displayed, ')')
|
||
|
|
const safeHidden = escapeHiddenTextSegment(hidden, '}')
|
||
|
|
return `(${safeDisplayed}){${safeHidden}}`
|
||
|
|
}
|
||
|
|
|
||
|
|
function readHiddenTextSegment(text = '', start = 0, closingChar = ')') {
|
||
|
|
let index = start
|
||
|
|
let value = ''
|
||
|
|
|
||
|
|
while (index < text.length) {
|
||
|
|
const char = text[index]
|
||
|
|
|
||
|
|
if (char === '\\') {
|
||
|
|
const nextChar = text[index + 1]
|
||
|
|
if (nextChar === undefined) return null
|
||
|
|
value += nextChar
|
||
|
|
index += 2
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
if (char === '\n' || char === '\r') return null
|
||
|
|
if (char === closingChar) {
|
||
|
|
return {
|
||
|
|
value,
|
||
|
|
end: index + 1,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
value += char
|
||
|
|
index += 1
|
||
|
|
}
|
||
|
|
|
||
|
|
return null
|
||
|
|
}
|
||
|
|
|
||
|
|
export function parseHiddenTextAt(text = '', start = 0) {
|
||
|
|
if (text[start] !== '(') return null
|
||
|
|
|
||
|
|
const displayed = readHiddenTextSegment(text, start + 1, ')')
|
||
|
|
if (!displayed) return null
|
||
|
|
if (text[displayed.end] !== '{') return null
|
||
|
|
|
||
|
|
const hidden = readHiddenTextSegment(text, displayed.end + 1, '}')
|
||
|
|
if (!hidden) return null
|
||
|
|
|
||
|
|
return {
|
||
|
|
start,
|
||
|
|
end: hidden.end,
|
||
|
|
displayed: displayed.value,
|
||
|
|
hidden: hidden.value,
|
||
|
|
raw: text.slice(start, hidden.end),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export function extractHiddenTextMatches(text = '') {
|
||
|
|
const matches = []
|
||
|
|
let index = 0
|
||
|
|
|
||
|
|
while (index < text.length) {
|
||
|
|
const match = parseHiddenTextAt(text, index)
|
||
|
|
if (match) {
|
||
|
|
matches.push(match)
|
||
|
|
index = match.end
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
index += 1
|
||
|
|
}
|
||
|
|
|
||
|
|
return matches
|
||
|
|
}
|
||
|
|
|
||
|
|
export function splitTextWithHiddenSyntax(text = '') {
|
||
|
|
const matches = extractHiddenTextMatches(text)
|
||
|
|
if (matches.length === 0) return null
|
||
|
|
|
||
|
|
const segments = []
|
||
|
|
let cursor = 0
|
||
|
|
|
||
|
|
for (const match of matches) {
|
||
|
|
if (match.start > cursor) {
|
||
|
|
segments.push({
|
||
|
|
type: 'text',
|
||
|
|
value: text.slice(cursor, match.start),
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
segments.push({
|
||
|
|
type: HIDDEN_TEXT_NODE_TYPE,
|
||
|
|
displayed: match.displayed,
|
||
|
|
hidden: match.hidden,
|
||
|
|
})
|
||
|
|
|
||
|
|
cursor = match.end
|
||
|
|
}
|
||
|
|
|
||
|
|
if (cursor < text.length) {
|
||
|
|
segments.push({
|
||
|
|
type: 'text',
|
||
|
|
value: text.slice(cursor),
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
return segments.filter((segment) => segment.type !== 'text' || segment.value)
|
||
|
|
}
|
||
|
|
|
||
|
|
export function renderHiddenTextPreviewHtml(displayed = '', hidden = '') {
|
||
|
|
const summary = escapeHtml(displayed || '未命名文本')
|
||
|
|
|
||
|
|
return [
|
||
|
|
`<span class="hidden-text-preview" data-hidden-text="true" title="${escapeHtml(serializeHiddenTextSyntax(displayed, hidden))}">`,
|
||
|
|
`<span class="hidden-text-preview__summary">${summary}</span>`,
|
||
|
|
'</span>',
|
||
|
|
].join('')
|
||
|
|
}
|
||
|
|
|
||
|
|
export function hiddenTextMarkdownItPlugin(md) {
|
||
|
|
md.inline.ruler.before('emphasis', 'hidden_text', (state, silent) => {
|
||
|
|
const match = parseHiddenTextAt(state.src, state.pos)
|
||
|
|
if (!match) return false
|
||
|
|
|
||
|
|
if (!silent) {
|
||
|
|
const token = state.push('hidden_text', '', 0)
|
||
|
|
token.meta = {
|
||
|
|
displayed: match.displayed,
|
||
|
|
hidden: match.hidden,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
state.pos = match.end
|
||
|
|
return true
|
||
|
|
})
|
||
|
|
|
||
|
|
md.renderer.rules.hidden_text = (tokens, index) => {
|
||
|
|
const meta = tokens[index]?.meta || {}
|
||
|
|
return renderHiddenTextPreviewHtml(meta.displayed, meta.hidden)
|
||
|
|
}
|
||
|
|
}
|