tool.xpcool.com/src/views/placeholder-image/PlaceholderImageView.vue

268 lines
7.8 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="page-wrap">
<h2 class="page-title">占位图生成</h2>
<p class="page-desc">前端开发常用的占位图Placeholder自定义尺寸与配色下载 PNG 或复制 SVG 代码纯本地生成</p>
<t-card class="tool-section">
<div class="preview-wrap">
<canvas ref="canvasRef" class="preview-canvas img-previewable" title="点击预览大图" @click="previewCanvas" />
</div>
<div class="grid-form">
<div class="form-item">
<span class="form-label">宽度</span>
<t-input-number v-model="width" :min="1" :max="2000" :step="10" class="num-input" />
</div>
<div class="form-item">
<span class="form-label">高度</span>
<t-input-number v-model="height" :min="1" :max="2000" :step="10" class="num-input" />
</div>
<div class="form-item">
<span class="form-label">背景色</span>
<input type="color" v-model="bgColor" class="color-input" />
<span class="hex-text">{{ bgColor.toUpperCase() }}</span>
</div>
<div class="form-item">
<span class="form-label">文字色</span>
<input type="color" v-model="textColor" class="color-input" />
<span class="hex-text">{{ textColor.toUpperCase() }}</span>
</div>
<div class="form-item full">
<span class="form-label">文字</span>
<t-input v-model="text" placeholder="显示在图片中央的文字(可换行)" class="text-input" />
</div>
<div class="form-item full">
<span class="form-label">字号</span>
<t-slider v-model="fontRatio" :min="5" :max="30" :step="1" class="slider" />
<span class="hex-text">{{ fontRatio }}%</span>
</div>
</div>
<div class="form-actions">
<t-button theme="primary" @click="downloadPng">下载 PNG</t-button>
<CopyButton :text="svgCode" label="复制 SVG 代码" />
</div>
</t-card>
<t-card class="tool-section" title="SVG 代码">
<pre class="code-block">{{ svgCode }}</pre>
</t-card>
<HistoryPanel
:items="history.items"
@select="onHistorySelect"
@remove="history.remove"
@clear="history.clear"
/>
<!-- 占位图放大预览 -->
<AppImageViewer v-model:visible="viewerVisible" v-model:index="viewerIndex" :images="viewerImages" />
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import CopyButton from '@/components/common/CopyButton.vue'
import HistoryPanel from '@/components/common/HistoryPanel.vue'
import { useHistory } from '@/composables/useHistory'
import { useImageViewer } from '@/composables/useImageViewer'
import AppImageViewer from '@/components/common/AppImageViewer.vue'
import { downloadDataUrl } from '@/composables/useDownload'
interface PlaceholderParams {
width: number
height: number
bgColor: string
textColor: string
text: string
fontRatio: number
}
const history = useHistory('placeholder-image')
const canvasRef = ref<HTMLCanvasElement | null>(null)
const width = ref(400)
const height = ref(300)
/** 点击画布放大预览(把当前画布导出为 PNG 后交给 ImageViewer */
const { visible: viewerVisible, index: viewerIndex, images: viewerImages, open: openViewer } = useImageViewer()
function previewCanvas() {
const canvas = canvasRef.value
if (canvas) openViewer([canvas.toDataURL('image/png')])
}
const bgColor = ref('#667eea')
const textColor = ref('#ffffff')
const text = ref('400 x 300')
const fontRatio = ref(12)
const W = () => Math.min(2000, Math.max(1, Math.round(width.value)))
const H = () => Math.min(2000, Math.max(1, Math.round(height.value)))
const FONT_RATIO = () => Math.min(30, Math.max(5, Math.round(fontRatio.value))) / 100
function draw() {
const canvas = canvasRef.value
if (!canvas) return
const w = W()
const h = H()
canvas.width = w
canvas.height = h
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.fillStyle = bgColor.value
ctx.fillRect(0, 0, w, h)
ctx.fillStyle = textColor.value
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const fontSize = Math.round(Math.min(w, h) * FONT_RATIO())
ctx.font = `${fontSize}px sans-serif`
const lines = text.value.split('\n')
lines.forEach((line, i) => {
const y = h / 2 + (i - (lines.length - 1) / 2) * fontSize * 1.25
ctx.fillText(line, w / 2, y)
})
}
/** 转义 SVG 文本 */
function escapeXml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
const svgCode = computed(() => {
const w = W()
const h = H()
const fontSize = Math.round(Math.min(w, h) * FONT_RATIO())
const lines = text.value.split('\n')
const tspans = lines
.map(
(line, i) =>
` <tspan x="50%" dy="${i === 0 ? 0 : fontSize * 1.25}">${escapeXml(line) || ' '}</tspan>`,
)
.join('\n')
return [
`<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">`,
` <rect width="100%" height="100%" fill="${bgColor.value}"/>`,
` <text x="50%" y="50%" fill="${textColor.value}" font-size="${fontSize}" text-anchor="middle" dominant-baseline="central" font-family="sans-serif">`,
tspans,
` </text>`,
`</svg>`,
].join('\n')
})
function downloadPng() {
const canvas = canvasRef.value
if (!canvas) return
downloadDataUrl(canvas.toDataURL('image/png'), `placeholder-${W()}x${H()}.png`)
saveHistory()
}
function saveHistory() {
const p: PlaceholderParams = {
width: W(),
height: H(),
bgColor: bgColor.value,
textColor: textColor.value,
text: text.value,
fontRatio: fontRatio.value,
}
history.add(JSON.stringify(p))
}
function onHistorySelect(t: string) {
try {
const p = JSON.parse(t) as PlaceholderParams
if (typeof p.width === 'number') width.value = p.width
if (typeof p.height === 'number') height.value = p.height
if (typeof p.bgColor === 'string') bgColor.value = p.bgColor
if (typeof p.textColor === 'string') textColor.value = p.textColor
if (typeof p.text === 'string') text.value = p.text
if (typeof p.fontRatio === 'number') fontRatio.value = p.fontRatio
draw()
} catch {
// 忽略解析失败的历史
}
}
watch([width, height, bgColor, textColor, text, fontRatio], draw, { immediate: true })
</script>
<style scoped>
.page-wrap {
max-width: 860px;
}
.preview-wrap {
display: flex;
justify-content: center;
padding: 16px;
border: 1px solid var(--td-border-level-1-color);
border-radius: 8px;
background: var(--td-bg-color-container);
margin-bottom: 16px;
}
.preview-canvas {
max-width: 100%;
height: auto;
border-radius: 4px;
}
.grid-form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px 16px;
}
.form-item {
display: flex;
align-items: center;
gap: 8px;
}
.form-item.full {
grid-column: 1 / -1;
}
.form-label {
flex-shrink: 0;
font-size: 14px;
color: var(--td-text-color-secondary);
}
.num-input {
width: 110px;
}
.color-input {
width: 36px;
height: 28px;
padding: 0;
border: none;
border-radius: 4px;
background: none;
cursor: pointer;
}
.hex-text {
font-family: ui-monospace, Consolas, monospace;
font-size: 12px;
color: var(--td-text-color-placeholder);
}
.text-input {
flex: 1;
}
.slider {
flex: 1;
min-width: 120px;
}
.form-actions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 16px;
flex-wrap: wrap;
}
.code-block {
margin: 0;
padding: 12px 16px;
border: 1px solid var(--td-border-level-1-color);
border-radius: 6px;
background: var(--td-bg-color-container);
font-family: ui-monospace, SFMono-Regular, Consolas, 'Courier New', monospace;
font-size: 13px;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-all;
color: var(--td-text-color-primary);
}
</style>