Files
text-mosaic/src/main.ts
T
2026-07-25 16:19:26 +08:00

281 lines
8.7 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">14%</output>
</span>
<span class="control__hint">留下多少原文词语</span>
<input id="retention-control" type="range" min="5" max="40" value="14" />
</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="85" max="115" 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()