1. 修复海报图片
This commit is contained in:
46
vite-tailwindcss/src/composables/drawXiSeal.js
Normal file
46
vite-tailwindcss/src/composables/drawXiSeal.js
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 在 canvas 上绘制圆内「囍」印章。
|
||||
* 用位图避免 html2canvas 对 CJK + transform/flex 居中错位。
|
||||
*/
|
||||
export function drawXiSeal(canvas, {
|
||||
size = 28,
|
||||
fontSize = 13,
|
||||
color = '#d4af37',
|
||||
borderColor = '#d4af37',
|
||||
fill = 'rgba(0, 0, 0, 0.15)',
|
||||
dpr = Math.min(3, window.devicePixelRatio || 2),
|
||||
} = {}) {
|
||||
if (!canvas) return
|
||||
const s = Math.max(12, Number(size) || 28)
|
||||
const fs = Math.max(8, Number(fontSize) || Math.round(s * 0.46))
|
||||
const ratio = Math.max(1, Number(dpr) || 2)
|
||||
|
||||
canvas.width = Math.round(s * ratio)
|
||||
canvas.height = Math.round(s * ratio)
|
||||
canvas.style.width = `${s}px`
|
||||
canvas.style.height = `${s}px`
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
ctx.setTransform(ratio, 0, 0, ratio, 0, 0)
|
||||
ctx.clearRect(0, 0, s, s)
|
||||
|
||||
const cx = s / 2
|
||||
const cy = s / 2
|
||||
const r = s / 2 - 0.75
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.arc(cx, cy, r, 0, Math.PI * 2)
|
||||
ctx.fillStyle = fill
|
||||
ctx.fill()
|
||||
ctx.lineWidth = 1
|
||||
ctx.strokeStyle = borderColor
|
||||
ctx.stroke()
|
||||
|
||||
ctx.fillStyle = color
|
||||
ctx.font = `700 ${fs}px "Noto Serif SC", "Source Han Serif SC", "Songti SC", "STSong", "SimSun", serif`
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
// 轻微上移做视觉光学居中(非 CSS transform,导出安全)
|
||||
ctx.fillText('囍', cx, cy + fs * 0.04)
|
||||
}
|
||||
@@ -129,6 +129,7 @@ import html2canvas from 'html2canvas'
|
||||
import { getConfig, getPosterConfig } from '@/api/wedding'
|
||||
import { normalizePosterConfig, posterSideMeta, resolvePosterSide } from '@/composables/posterDefaults'
|
||||
import { renderFestiveQr } from '@/composables/renderFestiveQr'
|
||||
import { drawXiSeal } from '@/composables/drawXiSeal'
|
||||
import { useAudio } from '@/composables/useAudio'
|
||||
import ClassicPoster from '@/views/hb/templates/ClassicPoster.vue'
|
||||
import SplitPoster from '@/views/hb/templates/SplitPoster.vue'
|
||||
@@ -375,6 +376,18 @@ async function savePosterImage() {
|
||||
try {
|
||||
cleanupBake = await bakeObjectFitImages(el)
|
||||
cleanupQr = await mountCaptureQr(el)
|
||||
// 导出前重绘囍印章位图,避免 html2canvas 对 CJK 文本错位
|
||||
try { await document.fonts?.ready } catch { /* ignore */ }
|
||||
el.querySelectorAll('canvas.sp-xi-mid, canvas.hb-xi-circle').forEach((c) => {
|
||||
const isClassic = c.classList.contains('hb-xi-circle')
|
||||
drawXiSeal(c, {
|
||||
size: isClassic ? 22 : 28,
|
||||
fontSize: isClassic ? 11 : 13,
|
||||
color: '#d4af37',
|
||||
borderColor: '#d4af37',
|
||||
fill: isClassic ? 'rgba(155, 0, 0, 0.5)' : 'rgba(0, 0, 0, 0.18)',
|
||||
})
|
||||
})
|
||||
// 按元素实际宽度抬升倍率:目标约 1600px 宽,最高 3x,避免手机导出糊成一片
|
||||
const baseW = Math.max(1, el.getBoundingClientRect().width || el.offsetWidth || 390)
|
||||
const scale = Math.min(3, Math.max(2.75, 1600 / baseW))
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
class-name="hb-name calligraphy"
|
||||
@animation-complete="namesRightReady = true"
|
||||
/>
|
||||
<span class="hb-xi-circle">囍</span>
|
||||
<canvas ref="xiSealRef" class="hb-xi-circle" width="44" height="44" aria-hidden="true"></canvas>
|
||||
<BlurText
|
||||
v-if="namesRightReady"
|
||||
:text="nameRight"
|
||||
@@ -141,10 +141,11 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import BlurText from '@/components/vue-bits/BlurText.vue'
|
||||
import TextType from '@/components/vue-bits/TextType.vue'
|
||||
import { heroFocusCss } from '@/composables/posterDefaults'
|
||||
import { drawXiSeal } from '@/composables/drawXiSeal'
|
||||
|
||||
const props = defineProps({
|
||||
cfg: { type: Object, required: true },
|
||||
@@ -155,12 +156,24 @@ defineEmits(['invite'])
|
||||
const stage = ref(0)
|
||||
const lineStage = ref(0)
|
||||
const namesRightReady = ref(false)
|
||||
const xiSealRef = ref(null)
|
||||
let timers = []
|
||||
|
||||
const heroObjectPosition = computed(() => heroFocusCss(props.cfg))
|
||||
const nameLeft = computed(() => `${props.cfg.sonLabel || ''}${props.cfg.sonName || ''}`)
|
||||
const nameRight = computed(() => `${props.cfg.daughterInLawLabel || ''}${props.cfg.daughterInLawName || ''}`)
|
||||
|
||||
async function paintXiSeal() {
|
||||
try { await document.fonts?.ready } catch { /* ignore */ }
|
||||
drawXiSeal(xiSealRef.value, {
|
||||
size: 22,
|
||||
fontSize: 11,
|
||||
color: '#d4af37',
|
||||
borderColor: '#d4af37',
|
||||
fill: 'rgba(155, 0, 0, 0.5)',
|
||||
})
|
||||
}
|
||||
|
||||
function later(fn, ms) {
|
||||
const id = setTimeout(fn, ms)
|
||||
timers.push(id)
|
||||
@@ -194,6 +207,17 @@ watch(
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(paintXiSeal)
|
||||
window.addEventListener('resize', paintXiSeal, { passive: true })
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', paintXiSeal)
|
||||
timers.forEach(clearTimeout)
|
||||
timers = []
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -326,15 +350,38 @@ watch(
|
||||
.hb-xi-l { left: 8%; }
|
||||
.hb-xi-r { right: 8%; }
|
||||
.hb-names {
|
||||
width: 100%; display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||
min-height: 28px; margin-bottom: 8px; opacity: 0; transition: opacity 0.4s ease;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 28px;
|
||||
margin-bottom: 8px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s ease;
|
||||
}
|
||||
.hb-names.is-show { opacity: 1; }
|
||||
.hb-name { color: #f7efe0 !important; font-size: 12px; letter-spacing: 0.12em; justify-content: center; }
|
||||
.hb-name {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
color: #f7efe0 !important;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.12em;
|
||||
justify-content: center;
|
||||
}
|
||||
.hb-name:first-child { padding-right: 18px; justify-content: flex-end; }
|
||||
.hb-name:last-child { padding-left: 18px; justify-content: flex-start; }
|
||||
.hb-xi-circle {
|
||||
width: 22px; height: 22px; border-radius: 50%; border: 1px solid var(--hb-gold);
|
||||
color: var(--hb-gold); font-size: 11px; display: inline-flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0; background: rgba(155, 0, 0, 0.5);
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin-left: -11px;
|
||||
margin-top: -11px;
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
.hb-title-wrap { margin: 4px 0 12px; min-height: 48px; opacity: 0; transition: opacity 0.35s ease; }
|
||||
.hb-title-wrap.is-show { opacity: 1; }
|
||||
|
||||
@@ -79,12 +79,12 @@
|
||||
</div>
|
||||
|
||||
<div class="sp-couple" :class="{ 'is-show': stage >= 5 }">
|
||||
<div class="sp-person">
|
||||
<div class="sp-person sp-person-l">
|
||||
<span class="sp-role calligraphy">新郎</span>
|
||||
<BlurText v-if="stage >= 5" :text="groom" :active="true" animate-by="letters" :delay="40" class-name="sp-name calligraphy" />
|
||||
</div>
|
||||
<span class="sp-xi-mid">囍</span>
|
||||
<div class="sp-person">
|
||||
<canvas ref="xiSealRef" class="sp-xi-mid" width="56" height="56" aria-hidden="true"></canvas>
|
||||
<div class="sp-person sp-person-r">
|
||||
<span class="sp-role calligraphy">新娘</span>
|
||||
<BlurText v-if="stage >= 5" :text="bride" :active="true" animate-by="letters" :delay="40" class-name="sp-name calligraphy" @animation-complete="bumpStage(6)" />
|
||||
</div>
|
||||
@@ -118,10 +118,11 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import BlurText from '@/components/vue-bits/BlurText.vue'
|
||||
import TextType from '@/components/vue-bits/TextType.vue'
|
||||
import { heroFocusCss } from '@/composables/posterDefaults'
|
||||
import { drawXiSeal } from '@/composables/drawXiSeal'
|
||||
|
||||
const props = defineProps({
|
||||
cfg: { type: Object, required: true },
|
||||
@@ -131,6 +132,7 @@ defineEmits(['invite'])
|
||||
|
||||
const stage = ref(0)
|
||||
const lineStage = ref(0)
|
||||
const xiSealRef = ref(null)
|
||||
let timers = []
|
||||
|
||||
const heroObjectPosition = computed(() => heroFocusCss(props.cfg))
|
||||
@@ -147,6 +149,17 @@ const bodyLines = computed(() => {
|
||||
const groom = computed(() => props.cfg.groomName || props.cfg.sonName || '')
|
||||
const bride = computed(() => props.cfg.brideName || props.cfg.daughterInLawName || '')
|
||||
|
||||
async function paintXiSeal() {
|
||||
try { await document.fonts?.ready } catch { /* ignore */ }
|
||||
drawXiSeal(xiSealRef.value, {
|
||||
size: 28,
|
||||
fontSize: 13,
|
||||
color: '#d4af37',
|
||||
borderColor: '#d4af37',
|
||||
fill: 'rgba(0, 0, 0, 0.18)',
|
||||
})
|
||||
}
|
||||
|
||||
function later(fn, ms) {
|
||||
timers.push(setTimeout(fn, ms))
|
||||
}
|
||||
@@ -179,6 +192,17 @@ watch(
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(paintXiSeal)
|
||||
window.addEventListener('resize', paintXiSeal, { passive: true })
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', paintXiSeal)
|
||||
timers.forEach(clearTimeout)
|
||||
timers = []
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -335,18 +359,40 @@ watch(
|
||||
.sp-cursor { color: var(--hb-gold); }
|
||||
|
||||
.sp-couple {
|
||||
position: relative; z-index: 1;
|
||||
display: flex; align-items: center; justify-content: center; gap: 14px;
|
||||
margin: 6px 0 14px; opacity: 0; transition: opacity 0.4s ease;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
margin: 6px 0 14px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s ease;
|
||||
}
|
||||
.sp-couple.is-show { opacity: 1; }
|
||||
.sp-person { display: flex; flex-direction: column; align-items: center; gap: 4px; min-width: 72px; }
|
||||
.sp-person {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.sp-person-l { padding-right: 22px; }
|
||||
.sp-person-r { padding-left: 22px; }
|
||||
.sp-role { font-size: 11px; color: rgba(247, 239, 224, 0.7); letter-spacing: 0.3em; }
|
||||
.sp-name { color: var(--hb-gold-soft) !important; font-size: 18px; letter-spacing: 0.2em; justify-content: center; }
|
||||
/* canvas 位图印章:不用 CSS transform,避免 html2canvas 错位 */
|
||||
.sp-xi-mid {
|
||||
width: 28px; height: 28px; border-radius: 50%; border: 1px solid var(--hb-gold);
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: 13px; color: var(--hb-gold); background: rgba(0, 0, 0, 0.15);
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-left: -14px;
|
||||
margin-top: -14px;
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.sp-meta {
|
||||
|
||||
Reference in New Issue
Block a user