feat: 初始化文字马赛克生成器
This commit is contained in:
+280
@@ -0,0 +1,280 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user