288 lines
8.8 KiB
TypeScript
288 lines
8.8 KiB
TypeScript
import './style.css'
|
|
import {
|
|
generateMosaic,
|
|
selectKeywords,
|
|
type KeywordCandidate,
|
|
} from './mosaic'
|
|
|
|
const SAMPLE_TEXT =
|
|
'我一直以为记忆会忠实地保存一切,后来才发现,每一次回想都在重新编造过去。'
|
|
|
|
const app = document.querySelector<HTMLDivElement>('#app')
|
|
|
|
if (!app) {
|
|
throw new Error('找不到应用挂载节点 #app')
|
|
}
|
|
|
|
app.innerHTML = `
|
|
<div class="page-shell">
|
|
<header class="hero">
|
|
<div class="hero__index" aria-hidden="true">TEXT / EROSION / 01</div>
|
|
<div class="hero__content">
|
|
<h1>文字马赛克</h1>
|
|
<p>让文字损坏,只留下几处可辨认的痕迹。</p>
|
|
</div>
|
|
<div class="hero__mark" aria-hidden="true">※</div>
|
|
</header>
|
|
|
|
<main>
|
|
<section class="workspace" aria-label="文字马赛克生成器">
|
|
<article class="panel panel--input">
|
|
<div class="panel__heading">
|
|
<div>
|
|
<span class="panel__number">01</span>
|
|
<h2>原始文本</h2>
|
|
</div>
|
|
<span id="input-count" class="counter">0 字</span>
|
|
</div>
|
|
<label class="sr-only" for="source-text">输入原始文本</label>
|
|
<textarea
|
|
id="source-text"
|
|
maxlength="3000"
|
|
spellcheck="false"
|
|
placeholder="在这里粘贴一段中文……"
|
|
></textarea>
|
|
</article>
|
|
|
|
<article class="panel panel--output">
|
|
<div class="panel__heading panel__heading--dark">
|
|
<div>
|
|
<span class="panel__number">02</span>
|
|
<h2>损坏结果</h2>
|
|
</div>
|
|
<span id="output-ratio" class="counter">—</span>
|
|
</div>
|
|
<div id="mosaic-output" class="mosaic-output" role="status"></div>
|
|
<div class="output-actions">
|
|
<button id="reroll-button" class="button button--light" type="button">
|
|
换一版
|
|
</button>
|
|
<button id="copy-button" class="button button--outline" type="button">
|
|
复制文本
|
|
</button>
|
|
</div>
|
|
</article>
|
|
</section>
|
|
|
|
<section class="control-deck" aria-label="生成参数">
|
|
<div class="control-deck__title">
|
|
<span>CONTROL SIGNAL</span>
|
|
<h2>侵蚀参数</h2>
|
|
</div>
|
|
|
|
<div class="controls">
|
|
<label class="control" for="retention-control">
|
|
<span class="control__heading">
|
|
<span>显影</span>
|
|
<output id="retention-value">15%</output>
|
|
</span>
|
|
<span class="control__hint">留下多少原文词语</span>
|
|
<input id="retention-control" type="range" min="0" max="100" value="15" />
|
|
</label>
|
|
|
|
<label class="control" for="volume-control">
|
|
<span class="control__heading">
|
|
<span>体量</span>
|
|
<output id="volume-value">100%</output>
|
|
</span>
|
|
<span class="control__hint">结果相对原文的总长度</span>
|
|
<input
|
|
id="volume-control"
|
|
type="range"
|
|
min="10"
|
|
max="1000"
|
|
step="10"
|
|
value="100"
|
|
/>
|
|
</label>
|
|
|
|
<label class="control" for="disorder-control">
|
|
<span class="control__heading">
|
|
<span>扰动</span>
|
|
<output id="disorder-value">58%</output>
|
|
</span>
|
|
<span class="control__hint">局部伸缩、重复与叠字强度</span>
|
|
<input id="disorder-control" type="range" min="0" max="100" value="58" />
|
|
</label>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="keyword-stage" aria-labelledby="keyword-title">
|
|
<div class="keyword-stage__heading">
|
|
<div>
|
|
<span class="panel__number">03</span>
|
|
<h2 id="keyword-title">保留词</h2>
|
|
<p>点按词语切换显影状态,原始顺序不会改变。</p>
|
|
</div>
|
|
<button id="reselect-button" class="text-button" type="button">自动重选</button>
|
|
</div>
|
|
<div id="keyword-list" class="keyword-list"></div>
|
|
</section>
|
|
</main>
|
|
|
|
<footer>
|
|
<span>LOCAL PROCESSING</span>
|
|
<p>文本只在当前浏览器中处理,不会上传。</p>
|
|
</footer>
|
|
</div>
|
|
|
|
<div id="toast" class="toast" role="status" aria-live="polite"></div>
|
|
`
|
|
|
|
const sourceInput = getElement<HTMLTextAreaElement>('source-text')
|
|
const inputCount = getElement<HTMLSpanElement>('input-count')
|
|
const outputRatio = getElement<HTMLSpanElement>('output-ratio')
|
|
const mosaicOutput = getElement<HTMLDivElement>('mosaic-output')
|
|
const keywordList = getElement<HTMLDivElement>('keyword-list')
|
|
const toast = getElement<HTMLDivElement>('toast')
|
|
const retentionControl = getElement<HTMLInputElement>('retention-control')
|
|
const volumeControl = getElement<HTMLInputElement>('volume-control')
|
|
const disorderControl = getElement<HTMLInputElement>('disorder-control')
|
|
const retentionValue = getElement<HTMLOutputElement>('retention-value')
|
|
const volumeValue = getElement<HTMLOutputElement>('volume-value')
|
|
const disorderValue = getElement<HTMLOutputElement>('disorder-value')
|
|
const rerollButton = getElement<HTMLButtonElement>('reroll-button')
|
|
const copyButton = getElement<HTMLButtonElement>('copy-button')
|
|
const reselectButton = getElement<HTMLButtonElement>('reselect-button')
|
|
|
|
let candidates: KeywordCandidate[] = []
|
|
let selectionSeed = 5
|
|
let noiseSeed = Date.now()
|
|
let toastTimer = 0
|
|
|
|
function getElement<T extends HTMLElement>(id: string): T {
|
|
const element = document.getElementById(id)
|
|
|
|
if (!element) {
|
|
throw new Error(`找不到页面元素 #${id}`)
|
|
}
|
|
|
|
return element as T
|
|
}
|
|
|
|
function showToast(message: string): void {
|
|
window.clearTimeout(toastTimer)
|
|
toast.textContent = message
|
|
toast.classList.add('toast--visible')
|
|
toastTimer = window.setTimeout(() => {
|
|
toast.classList.remove('toast--visible')
|
|
}, 1800)
|
|
}
|
|
|
|
function getRetention(): number {
|
|
return Number(retentionControl.value)
|
|
}
|
|
|
|
function renderKeywords(): void {
|
|
keywordList.replaceChildren()
|
|
|
|
if (candidates.length === 0) {
|
|
const empty = document.createElement('p')
|
|
empty.className = 'keyword-list__empty'
|
|
empty.textContent = sourceInput.value.trim()
|
|
? '没有识别到可保留的词语,可以尝试输入更长的中文文本。'
|
|
: '输入文字后,这里会出现可保留的词语。'
|
|
keywordList.append(empty)
|
|
return
|
|
}
|
|
|
|
for (const candidate of candidates) {
|
|
const button = document.createElement('button')
|
|
button.type = 'button'
|
|
button.className = 'keyword-token'
|
|
button.textContent = candidate.text
|
|
button.setAttribute('aria-pressed', String(candidate.selected))
|
|
|
|
if (candidate.selected) {
|
|
button.classList.add('keyword-token--selected')
|
|
}
|
|
|
|
button.addEventListener('click', () => {
|
|
candidate.selected = !candidate.selected
|
|
renderKeywords()
|
|
renderOutput()
|
|
})
|
|
keywordList.append(button)
|
|
}
|
|
}
|
|
|
|
function refreshCandidates(): void {
|
|
candidates = selectKeywords(sourceInput.value, getRetention(), selectionSeed)
|
|
renderKeywords()
|
|
renderOutput()
|
|
}
|
|
|
|
function renderOutput(): void {
|
|
const result = generateMosaic(sourceInput.value, candidates, {
|
|
volume: Number(volumeControl.value),
|
|
disorder: Number(disorderControl.value),
|
|
seed: noiseSeed,
|
|
})
|
|
|
|
mosaicOutput.textContent = result.output || '等待文字进入损坏区'
|
|
mosaicOutput.classList.toggle('mosaic-output--empty', result.output === '')
|
|
inputCount.textContent = `${result.inputLength} 字`
|
|
outputRatio.textContent =
|
|
result.inputLength === 0
|
|
? '—'
|
|
: `${result.outputLength} 字 / ${Math.round((result.outputLength / result.inputLength) * 100)}%`
|
|
}
|
|
|
|
function syncControls(): void {
|
|
retentionValue.value = `${retentionControl.value}%`
|
|
volumeValue.value = `${volumeControl.value}%`
|
|
disorderValue.value = `${disorderControl.value}%`
|
|
}
|
|
|
|
sourceInput.addEventListener('input', () => {
|
|
selectionSeed += 1
|
|
noiseSeed += 1
|
|
refreshCandidates()
|
|
})
|
|
|
|
retentionControl.addEventListener('input', () => {
|
|
syncControls()
|
|
refreshCandidates()
|
|
})
|
|
|
|
volumeControl.addEventListener('input', () => {
|
|
syncControls()
|
|
renderOutput()
|
|
})
|
|
|
|
disorderControl.addEventListener('input', () => {
|
|
syncControls()
|
|
renderOutput()
|
|
})
|
|
|
|
rerollButton.addEventListener('click', () => {
|
|
noiseSeed += 1
|
|
renderOutput()
|
|
})
|
|
|
|
reselectButton.addEventListener('click', () => {
|
|
selectionSeed += 1
|
|
refreshCandidates()
|
|
})
|
|
|
|
copyButton.addEventListener('click', async () => {
|
|
const text = mosaicOutput.textContent ?? ''
|
|
|
|
if (!sourceInput.value || !text) {
|
|
showToast('还没有可以复制的结果')
|
|
return
|
|
}
|
|
|
|
try {
|
|
await navigator.clipboard.writeText(text)
|
|
showToast('已复制纯文本')
|
|
} catch (error) {
|
|
console.error('复制文字马赛克失败', error)
|
|
showToast('复制失败,请手动选择文本')
|
|
}
|
|
})
|
|
|
|
sourceInput.value = SAMPLE_TEXT
|
|
syncControls()
|
|
refreshCandidates()
|