diff --git a/apps/web/src/components/title/TitleMiniPreview.tsx b/apps/web/src/components/title/TitleMiniPreview.tsx new file mode 100644 index 000000000..b2c676434 --- /dev/null +++ b/apps/web/src/components/title/TitleMiniPreview.tsx @@ -0,0 +1,271 @@ +/** + * 标题迷你 Canvas 预览(#2001) + * + * 渲染一张指定宽度的小 Canvas 预览标题效果,用于: + * - 预设卡片缩略图 + * - 样式面板顶部的实时预览 + * + * 与 titleCanvas.ts 渲染逻辑保持一致,但: + * - 固定分辨率(width × 宽高比约 2:1) + * - 不调用 ffmpeg,只做视觉预览 + * - 支持背景色块、描边宽度/颜色、阴影参数化、行距、自动换行 + */ +import React, { useEffect, useRef } from "react" +import type { TitleStyleSettings } from "@/components/title/settings" +import { getFontFamily } from "@/components/title/constants" + +interface Props { + settings: TitleStyleSettings + width?: number + sampleText?: string + /** 背景(预览用,默认深色渐变模拟视频底),transparent=true 时忽略 */ + background?: string + /** 高度(可选,默认按 portrait 选比例) */ + height?: number + /** 透明背景(卡片/编辑器预览叠加在图片上时使用) */ + transparent?: boolean + /** 纵向竖屏预览(9:16),true 时 aspect=16/9 适配手机视频比例 */ + portrait?: boolean +} + +/** 按 maxCharsPerLine 自动换行 */ +function wrapLines(text: string, maxChars: number): string[] { + const manual = text + .split(/[//\n]/) + .map((l) => l.trim()) + .filter(Boolean) + if (!maxChars || maxChars <= 0) return manual + const out: string[] = [] + for (const line of manual) { + if (line.length <= maxChars) { + out.push(line) + continue + } + let cur = "" + for (const ch of line) { + cur += ch + if (cur.length >= maxChars) { + out.push(cur) + cur = "" + } + } + if (cur) out.push(cur) + } + return out +} + +const TitleMiniPreview: React.FC = ({ + settings, + width = 200, + sampleText, + background = "linear-gradient(135deg,#1f2937,#111827)", + height, + transparent = false, + portrait = false, +}) => { + const canvasRef = useRef(null) + const h = height ?? Math.round(width * (portrait ? 16 / 9 : 1 / 1.8)) + const text = (sampleText || "预览标题").trim() || "预览标题" + + useEffect(() => { + let cancelled = false + const draw = () => { + if (cancelled) return + const cvs = canvasRef.current + if (!cvs) return + const dpr = window.devicePixelRatio || 1 + cvs.width = width * dpr + cvs.height = h * dpr + cvs.style.width = `${width}px` + cvs.style.height = `${h}px` + const ctx = cvs.getContext("2d") + if (!ctx) return + ctx.scale(dpr, dpr) + ctx.clearRect(0, 0, width, h) + + // 背景(transparent 时跳过,用于叠加在图片上) + if (!transparent) { + ctx.fillStyle = "#111827" + ctx.fillRect(0, 0, width, h) + } + + // 分辨率缩放:以 360 宽为基准(对应 720p 的一半),与外层 previewScale/previewR 保持一致 + const r = previewR + + // 字体 + const size = r(settings.size) + const ff = getFontFamily(settings.font) + const parts: string[] = [] + if (settings.italic) parts.push("italic") + if (settings.bold) parts.push("bold") + parts.push(`${size}px`, ff) + ctx.font = parts.join(" ") + ctx.textAlign = "center" + ctx.textBaseline = "middle" + ctx.fillStyle = settings.color + ctx.lineJoin = "round" + + // 阴影 + const shadowEnabled = !!settings.shadow + const prevShadow = { + c: ctx.shadowColor, + b: ctx.shadowBlur, + ox: ctx.shadowOffsetX, + oy: ctx.shadowOffsetY, + } + if (shadowEnabled) { + ctx.shadowColor = settings.shadowColor ?? "rgba(0,0,0,0.8)" + ctx.shadowBlur = r(settings.shadowBlur ?? 4) + ctx.shadowOffsetX = r(settings.shadowOffsetX ?? 2) + ctx.shadowOffsetY = r(settings.shadowOffsetY ?? 2) + } + + // 换行 + const lines = wrapLines(text, settings.maxCharsPerLine ?? 0) + const lineH = size * (settings.lineHeight ?? 1.2) + const totalH = lines.length * lineH + let startY: number + if (settings.position === "top") { + startY = size / 2 + r(settings.marginTop ?? 24) + } else if (settings.position === "center") { + startY = h / 2 - totalH / 2 + size / 2 + } else { + // bottom + const botMargin = portrait ? r(24) : r(16) + startY = h - totalH - botMargin + size / 2 + } + let centerX = width / 2 + if (settings.position === "custom" && settings.posX != null) { + centerX = (settings.posX / 100) * width + } + + // 背景块 + if (settings.bgEnabled) { + const pad = r(settings.bgPadding ?? 12) + const rad = r(settings.bgRadius ?? 8) + let maxLineW = 0 + for (const l of lines) { + const m = ctx.measureText(l) + if (m.width > maxLineW) maxLineW = m.width + } + const bw = maxLineW + pad * 2 + const bh = totalH + pad * 2 + const bx = centerX - bw / 2 + const by = startY - size / 2 - pad + (size - lineH) / 2 + ctx.shadowColor = "rgba(0,0,0,0)" + ctx.shadowBlur = 0 + ctx.fillStyle = settings.bgColor ?? "rgba(0,0,0,0.5)" + roundRect(ctx, bx, by, bw, bh, rad) + ctx.fill() + // 关键修复:画完背景块后必须把 fillStyle 重置为文字颜色, + // 否则后续 fillText 会用 bgColor 填充文字,导致「文字看不见只剩色块」 + ctx.fillStyle = settings.color + // 恢复阴影 + if (shadowEnabled) { + ctx.shadowColor = settings.shadowColor ?? "rgba(0,0,0,0.8)" + ctx.shadowBlur = r(settings.shadowBlur ?? 4) + ctx.shadowOffsetX = r(settings.shadowOffsetX ?? 2) + ctx.shadowOffsetY = r(settings.shadowOffsetY ?? 2) + } + } + + // 描边(先画,再画填充) + const strokeEnabled = !!settings.stroke && (settings.strokeWidth ?? 0) > 0 + lines.forEach((line, i) => { + const y = startY + i * lineH + if (strokeEnabled) { + ctx.shadowColor = "rgba(0,0,0,0)" + ctx.shadowBlur = 0 + ctx.lineWidth = r(settings.strokeWidth ?? 4) + ctx.strokeStyle = settings.strokeColor ?? "#000000" + ctx.strokeText(line, centerX, y) + // 恢复阴影 + if (shadowEnabled) { + ctx.shadowColor = settings.shadowColor ?? "rgba(0,0,0,0.8)" + ctx.shadowBlur = r(settings.shadowBlur ?? 4) + ctx.shadowOffsetX = r(settings.shadowOffsetX ?? 2) + ctx.shadowOffsetY = r(settings.shadowOffsetY ?? 2) + } + } + ctx.fillText(line, centerX, y) + }) + + // 恢复 + ctx.shadowColor = prevShadow.c + ctx.shadowBlur = prevShadow.b + ctx.shadowOffsetX = prevShadow.ox + ctx.shadowOffsetY = prevShadow.oy + } + // 计算当前字号(draw() 内部同样逻辑,抽出来供 fontString 复用) + const previewScale = width / 360 + const previewR = (v: number) => Math.round(v * previewScale) + const buildFontString = () => { + const size = previewR(settings.size) + const ff = getFontFamily(settings.font) + const parts: string[] = [] + if (settings.italic) parts.push("italic") + if (settings.bold) parts.push("bold") + parts.push(`${size}px`, ff) + return parts.join(" ") + } + + // Web Font 加载保障: + // 1) 等 document.fonts.ready(CSS @font-face 首次可用) + // 2) 显式 FontFaceSet.load(fontString, text) 触发浏览器真正下载并加载 + // 当前字体到 Canvas 可用,避免首次绘制用 fallback 字体画出错字/色块 + const doDrawWhenReady = async () => { + try { + if (typeof document !== "undefined" && document.fonts) { + await document.fonts.ready + try { + await document.fonts.load(buildFontString(), text) + } catch { + /* ignore */ + } + } + } finally { + if (!cancelled) draw() + } + } + doDrawWhenReady() + return () => { + cancelled = true + } + }, [settings, width, h, text, transparent, portrait, background]) + + return ( + + ) +} + +function roundRect( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + w: number, + h: number, + r: number, +) { + const rr = Math.min(r, w / 2, h / 2) + ctx.beginPath() + ctx.moveTo(x + rr, y) + ctx.lineTo(x + w - rr, y) + ctx.quadraticCurveTo(x + w, y, x + w, y + rr) + ctx.lineTo(x + w, y + h - rr) + ctx.quadraticCurveTo(x + w, y + h, x + w - rr, y + h) + ctx.lineTo(x + rr, y + h) + ctx.quadraticCurveTo(x, y + h, x, y + h - rr) + ctx.lineTo(x, y + rr) + ctx.quadraticCurveTo(x, y, x + rr, y) + ctx.closePath() +} + +export default TitleMiniPreview diff --git a/apps/web/src/components/title/TitleStylePanel.css b/apps/web/src/components/title/TitleStylePanel.css new file mode 100644 index 000000000..58d88efcc --- /dev/null +++ b/apps/web/src/components/title/TitleStylePanel.css @@ -0,0 +1,458 @@ +/* ============================================================ + TitleStylePanel 标题样式面板 — 独立共用样式(#1809 ⑦) + + 从 generate.css 抽取的标题样式区块,供「智能剪辑」与「AI数字人」 + 两个页面共用。AI数字人页面不引入 generate.css,直接由 + TitleStylePanel.tsx import 本文件,保证 24 个 T 预设格子的网格布局、 + 配色描边、选中态与智能剪辑页面完全一致。 + + 注意:本文件规则与 generate.css 中同名规则一一对应、取值相同; + 智能剪辑页面两处同时存在时同优先级同值,不改变其原有呈现。 + ============================================================ */ + +/* ── 区块容器 ── */ +.xx-title-style-section { + margin-top: 22px; + padding-top: 20px; + border-top: 1px solid var(--border-light); +} + +.xx-section-subtitle { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + margin: 0 0 16px; +} + +.xx-title-style-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; + margin-bottom: 14px; +} + +.xx-half-field { + margin-bottom: 0; +} + +.xx-field-label-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +.xx-field-label-row label { + margin-bottom: 0; +} + +.xx-field-value { + font-size: 13px; + font-weight: 600; + color: var(--primary-color); +} + +/* ── 共用表单字段(位置/字体下拉) ── */ +.xx-title-style-section .xx-form-field { + margin-bottom: 14px; +} + +.xx-title-style-section .xx-form-field:last-child { + margin-bottom: 0; +} + +.xx-title-style-section .xx-form-field label { + display: block; + font-weight: 600; + margin-bottom: 8px; + font-size: 13px; + color: var(--text-primary); +} + +.xx-title-style-section .xx-form-field select, +.xx-title-style-section .xx-form-field input { + width: 100%; + height: 44px; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: var(--bg-primary); + padding: 0 14px; + font-size: 14px; + outline: 0; + transition: 0.15s ease; + color: var(--text-primary); +} + +.xx-title-style-section .xx-form-field select:focus, +.xx-title-style-section .xx-form-field input:focus { + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1); +} + +/* ── 字号滑块 ── */ +.xx-slider { + width: 100%; + height: 6px; + -webkit-appearance: none; + appearance: none; + background: var(--border-color); + border-radius: 3px; + outline: none; + cursor: pointer; +} + +.xx-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 18px; + height: 18px; + background: var(--primary-color); + border-radius: 50%; + cursor: pointer; + box-shadow: 0 2px 6px rgba(79, 70, 229, 0.3); +} + +.xx-slider::-moz-range-thumb { + width: 18px; + height: 18px; + background: var(--primary-color); + border-radius: 50%; + cursor: pointer; + border: none; + box-shadow: 0 2px 6px rgba(79, 70, 229, 0.3); +} + +/* ── 标题预设卡片网格(24 个 T 格子) ── */ +.xx-title-presets-grid { + display: grid; + grid-template-columns: repeat(6, 52px); + gap: 1px; +} + +.xx-title-preset-card { + display: flex; + align-items: center; + justify-content: center; + width: 52px; + height: 52px; + padding: 0; + background: #404040; + border: 2px solid transparent; + border-radius: 8px; + cursor: pointer; + transition: all 0.15s; +} + +.xx-title-preset-card:hover { + border-color: #666; + background: #4d4d4d; +} + +.xx-title-preset-card.active { + border-color: #409eff; + background: #4d4d4d; +} + +.xx-title-preset-preview-text { + font-size: 32px; + line-height: 1; + user-select: none; +} + +/* ── 样式按钮组(加粗/斜体/描边/阴影) ── */ +.xx-style-btns { + display: flex; + gap: 8px; +} + +.xx-style-btn { + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: var(--bg-primary); + cursor: pointer; + font-size: 15px; + color: var(--text-secondary); + transition: all 0.15s; +} + +.xx-style-btn:hover { + border-color: var(--primary-300); + color: var(--primary-color); +} + +.xx-style-btn.active { + background: var(--primary-color); + border-color: var(--primary-color); + color: #fff; +} + +/* ============================================================ + #2001 爆款标题样式面板升级 — 新增样式(ts- 前缀) + ============================================================ */ + +.ts-panel { + position: relative; +} + +/* 预览 */ +.ts-preview-wrap { + margin-bottom: 14px; + display: flex; + justify-content: center; + padding: 10px; + background: #0f172a; + border-radius: 8px; +} + +/* 表单字段 */ +.ts-form-field { + margin-bottom: 12px; +} +.ts-form-field label { + display: block; + font-weight: 600; + margin-bottom: 6px; + font-size: 12px; + color: var(--text-primary, #1f2937); +} +.ts-field-label-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 6px; +} +.ts-field-value { + font-size: 12px; + font-weight: 600; + color: var(--primary-color, #7c3aed); +} +.ts-row-2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} +.ts-half { + margin-bottom: 0; +} + +.ts-select { + width: 100%; + height: 34px; + border: 1px solid var(--border-color, #e5e7eb); + border-radius: 6px; + background: var(--bg-primary, #fff); + padding: 0 10px; + font-size: 13px; + outline: 0; + color: var(--text-primary, #1f2937); +} +.ts-select:focus { + border-color: var(--primary-color, #7c3aed); + box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.1); +} +.ts-input { + width: 100%; + height: 34px; + border: 1px solid var(--border-color, #e5e7eb); + border-radius: 6px; + padding: 0 10px; + font-size: 13px; + outline: 0; +} + +.ts-slider { + width: 100%; + height: 4px; + -webkit-appearance: none; + appearance: none; + background: #e5e7eb; + border-radius: 2px; + outline: none; +} +.ts-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + background: #7c3aed; + cursor: pointer; + border: 2px solid #fff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); +} +.ts-slider::-moz-range-thumb { + width: 16px; + height: 16px; + border-radius: 50%; + background: #7c3aed; + cursor: pointer; + border: 2px solid #fff; +} + +/* 样式按钮 B/I/S/☁ */ +.ts-style-btns { + display: flex; + gap: 6px; +} +.ts-style-btn { + width: 34px; + height: 34px; + border-radius: 6px; + border: 1px solid #e5e7eb; + background: #fff; + cursor: pointer; + font-size: 14px; + transition: 0.15s; + color: #374151; + display: inline-flex; + align-items: center; + justify-content: center; +} +.ts-style-btn:hover { + border-color: #7c3aed; + color: #7c3aed; +} +.ts-style-btn.active { + background: #faf5ff; + color: #6d28d9; + border-color: #7c3aed; + font-weight: 700; +} + +/* 色板 */ +.ts-color-row { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} +.ts-color-swatch { + width: 24px; + height: 24px; + border-radius: 4px; + border: 2px solid #fff; + box-shadow: 0 0 0 1px #e5e7eb; + cursor: pointer; + padding: 0; + transition: 0.15s; +} +.ts-color-swatch:hover { + transform: scale(1.1); +} +.ts-color-swatch.active { + box-shadow: 0 0 0 2px #7c3aed; + transform: scale(1.1); +} +.ts-color-custom { + background: repeating-conic-gradient(#ccc 0% 25%, #fff 0% 50%) 50%/8px 8px; + color: #666; + font-size: 14px; + line-height: 20px; +} +.ts-color-native { + width: 0; + height: 0; + border: 0; + padding: 0; +} + +/* 预设网格 10个 - 5列 */ +.ts-presets-grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 6px; +} +.ts-preset-card { + border: 1px solid #e5e7eb; + border-radius: 6px; + background: #fff; + padding: 4px; + cursor: pointer; + transition: 0.15s; + display: flex; + flex-direction: column; + gap: 4px; +} +.ts-preset-card:hover { + border-color: #7c3aed; +} +.ts-preset-card.active { + border-color: #7c3aed; + background: #faf5ff; + box-shadow: 0 0 0 1px #7c3aed; +} +.ts-preset-preview { + height: 34px; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + border-radius: 4px; + background: #0f172a; +} +.ts-preset-preview canvas { + max-width: 100%; + max-height: 100%; +} +.ts-preset-meta { + display: flex; + align-items: center; + gap: 2px; + font-size: 10px; + color: #4b5563; + justify-content: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding: 0 2px 2px; +} +.ts-preset-emoji { + font-size: 11px; +} +.ts-preset-label { + overflow: hidden; + text-overflow: ellipsis; +} + +.ts-toggle-row label { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + margin-bottom: 10px; +} +.ts-toggle-row input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: #7c3aed; +} + +/* Tabs 紧凑样式 */ +.xx-title-style-section .ant-tabs-nav { + margin-bottom: 10px; +} +.xx-title-style-section .ant-tabs-tab { + font-size: 12px !important; + padding: 6px 8px !important; +} + +/* 标题模板入口按钮(#2003) */ +.ts-template-btn { + border: none; + background: transparent; + color: var(--primary-color, #7c3aed); + font-size: 12px; + cursor: pointer; + padding: 2px 0; + font-weight: 500; +} +.ts-template-btn:hover { + text-decoration: underline; +} diff --git a/apps/web/src/components/title/TitleStyleParamsTab.tsx b/apps/web/src/components/title/TitleStyleParamsTab.tsx new file mode 100644 index 000000000..39e4a0797 --- /dev/null +++ b/apps/web/src/components/title/TitleStyleParamsTab.tsx @@ -0,0 +1,445 @@ +/** + * 标题样式参数 Tab 面板(共享组件) + * + * 包含:基础/描边/阴影/背景/排版/封面 共 6 个 Tab + * 仅负责 UI 渲染和参数 patch 回调,不维护 state、不调 API + */ +import React, { useState } from "react" +import { Tabs } from "antd" +import type { TitleStyleSettings } from "./settings" +import { + FONT_OPTIONS, + TITLE_COLOR_PALETTE, + STROKE_COLOR_PALETTE, + BG_COLOR_PALETTE, +} from "./constants" + +export interface PositionOption { + value: string + label: string +} + +export interface FontOption { + value: string + label: string + family: string + tag?: "hot" | "new" +} + +export interface TitleStyleParamsTabProps { + settings: TitleStyleSettings + onUpdatePosition: (p: string) => void + onUpdateFont: (f: string) => void + onUpdateSize: (v: number) => void + onToggleBold: () => void + onToggleItalic: () => void + onToggleStroke: () => void + onToggleShadow: () => void + onUpdatePatch: (patch: Partial) => void + positionOptions: PositionOption[] + fontOptions?: FontOption[] + /** 是否显示「封面」Tab(独立封面标题开关) */ + showCoverToggle?: boolean + /** 封面独立标题开关状态 */ + coverEnabled?: boolean + /** 封面开关变化 */ + onToggleCover?: (enabled: boolean) => void +} + +/* ── Slider 行 ── */ +const SliderRow: React.FC<{ + label: string + value: number + min: number + max: number + step?: number + unit?: string + onChange: (v: number) => void +}> = ({ label, value, min, max, step = 1, unit = "px", onChange }) => ( +
+
+ + + {value} + {unit} + +
+ onChange(Number(e.target.value))} + /> +
+) + +/* ── 色板 ── */ +const ColorPicker: React.FC<{ + label?: string + value: string + palette: string[] + onChange: (c: string) => void +}> = ({ label, value, palette, onChange }) => { + const [customOpen, setCustomOpen] = useState(false) + return ( +
+ {label && } +
+ {palette.map((c) => ( + + { + onChange(e.target.value) + setCustomOpen(false) + }} + style={{ + opacity: customOpen ? 1 : 0, + position: customOpen ? "static" : "absolute", + pointerEvents: customOpen ? "auto" : "none", + width: customOpen ? 28 : 0, + height: customOpen ? 28 : 0, + border: "none", + padding: 0, + cursor: "pointer", + background: "transparent", + }} + /> +
+
+ 当前:{value} +
+
+ ) +} + +const TitleStyleParamsTab: React.FC = ({ + settings, + onUpdatePosition, + onUpdateFont, + onUpdateSize, + onToggleBold, + onToggleItalic, + onToggleStroke, + onToggleShadow, + onUpdatePatch, + positionOptions, + fontOptions = FONT_OPTIONS, + showCoverToggle = false, + coverEnabled = false, + onToggleCover, +}) => { + const upd = onUpdatePatch + return ( + +
+
+ + +
+
+ + +
+
+ +
+ +
+ + + + +
+
+ upd({ color: c })} + /> + + ), + }, + { + key: "stroke", + label: "描边", + children: ( + <> +
+ +
+ {settings.stroke && ( + <> + upd({ strokeWidth: v })} + /> + upd({ strokeColor: c })} + /> + + )} + + ), + }, + { + key: "shadow", + label: "阴影", + children: ( + <> +
+ +
+ {settings.shadow && ( + <> + upd({ shadowOffsetX: v })} + /> + upd({ shadowOffsetY: v })} + /> + upd({ shadowBlur: v })} + /> +
+ + upd({ shadowColor: e.target.value })} + placeholder="rgba(0,0,0,0.8)" + /> +
+ + )} + + ), + }, + { + key: "bg", + label: "背景", + children: ( + <> +
+ +
+ {settings.bgEnabled && ( + <> + upd({ bgColor: c })} + /> + upd({ bgPadding: v })} + /> + upd({ bgRadius: v })} + /> + + )} + + ), + }, + { + key: "layout", + label: "排版", + children: ( + <> + upd({ maxCharsPerLine: v })} + /> +
+ 0 = 不自动换行(按 / 手动分行) +
+ upd({ lineHeight: Number(v.toFixed(2)) })} + /> + upd({ marginTop: v })} + /> + + ), + }, + ...(showCoverToggle + ? [ + { + key: "cover", + label: "封面", + children: ( +
+ +
+ ), + }, + ] + : []), + ]} + /> + ) +} + +export default TitleStyleParamsTab diff --git a/apps/web/src/components/title/TitleTemplateEditor.tsx b/apps/web/src/components/title/TitleTemplateEditor.tsx index 4abe8a0b5..4c19002b1 100644 --- a/apps/web/src/components/title/TitleTemplateEditor.tsx +++ b/apps/web/src/components/title/TitleTemplateEditor.tsx @@ -1,29 +1,30 @@ /** - * 标题模板编辑器(v3 重构) + * 标题模板编辑器(公共组件) * * - Modal 弹窗 860px 宽 - * - 左侧:300px 竖屏预览区(图片背景+暗色渐变遮罩+透明 Canvas 叠字)+ 模板名称输入框 - * - 右侧:参数 Tab 面板(基础/描边/阴影/背景/排版),复用 TitleStylePanel 的 paramsOnly 模式 + * - 左侧:300px 竖屏预览区(图片背景+暗角+透明 Canvas 叠字)+ 模板名称输入 + * - 右侧:参数 Tab 面板(基础/描边/阴影/背景/排版),复用 TitleStyleParamsTab * - 底部:取消 / 保存模板 按钮 - * - 内置模板编辑时保存会创建副本(带"副本"逻辑由 handleSave 处理) + * - 内置模板编辑时保存会创建副本(带"副本"逻辑由 onSave 的调用方处理) */ import React, { useEffect, useMemo, useState } from "react" import { Modal, Button, Input, message } from "antd" -import TitleStylePanel from "../../pages/generate/components/title/TitleStylePanel" -import TitleMiniPreview from "../../pages/generate/components/title/TitleMiniPreview" -import { POSITION_OPTIONS } from "../../pages/generate/constants" -import { FONT_OPTIONS } from "./constants" -import type { TitleSettings } from "../../pages/generate/types" -import { DEFAULT_TITLE_SETTINGS_FULL } from "../../pages/generate/types" +import type { TitleStyleSettings } from "./settings" +import { DEFAULT_TITLE_STYLE_SETTINGS } from "./settings" import { titleStyleConfigToCamel, camelToTitleStyleConfig } from "./utils" import type { TitleTemplate } from "./template-types" import type { TitleStyleConfig } from "./types" +import { POSITION_OPTIONS } from "./position-options" +import { FONT_OPTIONS } from "./constants" +import TitleMiniPreview from "./TitleMiniPreview" +import TitleStyleParamsTab from "./TitleStyleParamsTab" +import "./TitleTemplate.css" +import "./TitleStylePanel.css" interface Props { open: boolean template: TitleTemplate onClose: () => void - /** 用户点击保存:将编辑结果回调给父组件(父组件统一做 CRUD,避免双 hook 实例不同步) */ onSave: (data: { name: string; emoji: string; style: Partial }) => void } @@ -31,10 +32,9 @@ interface Props { const EDITOR_BG = "/title-templates/portrait1.jpg" const TitleTemplateEditor: React.FC = ({ open, template, onClose, onSave }) => { - const [settings, setSettings] = useState(() => ({ - ...DEFAULT_TITLE_SETTINGS_FULL, + const [settings, setSettings] = useState(() => ({ + ...DEFAULT_TITLE_STYLE_SETTINGS, ...titleStyleConfigToCamel(template.style || {}), - title: "预览标题文字", })) const [formName, setFormName] = useState(template.name || "") const [formEmoji, setFormEmoji] = useState(template.emoji || "✨") @@ -43,16 +43,15 @@ const TitleTemplateEditor: React.FC = ({ open, template, onClose, onSave useEffect(() => { if (open) { setSettings({ - ...DEFAULT_TITLE_SETTINGS_FULL, + ...DEFAULT_TITLE_STYLE_SETTINGS, ...titleStyleConfigToCamel(template.style || {}), - title: "预览标题文字", }) setFormName(template.name || "") setFormEmoji(template.emoji || "✨") } }, [open, template]) - const upd = (patch: Partial) => setSettings((s) => ({ ...s, ...patch })) + const upd = (patch: Partial) => setSettings((s) => ({ ...s, ...patch })) const handleSave = () => { const name = formName.trim() @@ -69,11 +68,11 @@ const TitleTemplateEditor: React.FC = ({ open, template, onClose, onSave } } - // 编辑器内的预览用 settings:字号适配竖屏 - const previewSettings = useMemo(() => { - // 竖屏宽度 200px,按比例缩放字号,让预览看起来协调 - return { ...settings, size: Math.round(settings.size * 0.55) } - }, [settings]) + // 编辑器预览 settings:竖屏宽度 200px,字号按比例缩放 + const previewSettings = useMemo( + () => ({ ...settings, size: Math.round(settings.size * 0.55) }), + [settings], + ) return ( = ({ open, template, onClose, onSave {/* 右侧:参数 Tab */}
- upd({ position: p, posX: null, posY: null })} onUpdateFont={(f) => upd({ font: f })} @@ -155,15 +154,9 @@ const TitleTemplateEditor: React.FC = ({ open, template, onClose, onSave }) } onToggleShadow={() => upd({ shadow: !settings.shadow })} - onApplyPreset={() => { - /* 编辑器内不使用系统预设快捷键 */ - }} - onUpdateStyle={(patch) => upd(patch)} - activePreset={null} - titlePresets={[]} - POSITION_OPTIONS={POSITION_OPTIONS} - FONT_OPTIONS={FONT_OPTIONS} - paramsOnly + onUpdatePatch={upd} + positionOptions={POSITION_OPTIONS} + fontOptions={FONT_OPTIONS} />
diff --git a/apps/web/src/components/title/TitleTemplateSelector.tsx b/apps/web/src/components/title/TitleTemplateSelector.tsx new file mode 100644 index 000000000..27d8f46a5 --- /dev/null +++ b/apps/web/src/components/title/TitleTemplateSelector.tsx @@ -0,0 +1,343 @@ +/** + * 标题模板选择器 — 大卡片网格(共享组件) + * + * 渲染「我的模板」+「系统模板」两个分组的 3:4 竖版大圆角卡片: + * - 卡片上半:示例背景图 + vignette 暗角 + 透明 Canvas 大字预览 + * - 卡片下半:emoji + 名称 + 系统/我的标签 + 始终可见的编辑/复制/导出/删除按钮 + * - 选中紫色边框;右上角「新建模板」按钮;点编辑/新建弹 TitleTemplateEditor + * + * Props 通用化,不耦合业务 state。 + */ +import React, { useCallback, useMemo, useState } from "react" +import { Button, message, Popconfirm } from "antd" +import { + PlusOutlined, + EditOutlined, + CopyOutlined, + DeleteOutlined, + ExportOutlined, + CheckOutlined, +} from "@ant-design/icons" +import type { TitleTemplate } from "./template-types" +import type { TitleStyleSettings } from "./settings" +import { DEFAULT_TITLE_STYLE_SETTINGS } from "./settings" +import { + titleStyleConfigToCamel, + camelToTitleStyleConfig, + templateToPreviewSettings, +} from "./utils" +import { useTitleTemplates } from "./useTitleTemplates" +import TitleMiniPreview from "./TitleMiniPreview" +import TitleTemplateEditor from "./TitleTemplateEditor" +import "./TitleTemplate.css" +import "./TitleStylePanel.css" + +export interface TitleTemplateSelectorProps { + /** 当前选中模板 id(受控) */ + value?: string | null + /** 选中模板时回调(templateId, fullStyleSettings, template) */ + onChange?: (templateId: string, style: TitleStyleSettings, template: TitleTemplate) => void + /** 是否显示编辑器入口(新建/编辑按钮),默认 true */ + showEditor?: boolean + /** 显示哪些分组,默认全部 */ + categories?: Array<"system" | "custom"> + /** 使用场景标识(仅作 data-attr,不影响样式) */ + context?: string +} + +/* ── 卡片预览背景图池(按 index 轮换) ── */ +const PREVIEW_BG_IMAGES = [ + "/title-templates/portrait1.jpg", + "/title-templates/portrait2.jpg", + "/title-templates/scene1.jpg", +] + +/* ── 预览容器:用 ref 测量宽度后再渲染透明 Canvas,保证文字清晰 ── */ +const FillPreview: React.FC<{ + settings: TitleStyleSettings + sampleText: string + portrait?: boolean +}> = ({ settings, sampleText, portrait }) => { + const [w, setW] = useState(0) + // 首次挂载后测量一次 + const setRef = useCallback((el: HTMLDivElement | null) => { + if (el) setW(Math.floor(el.clientWidth)) + }, []) + return ( +
+ {w > 0 && ( + + )} +
+ ) +} + +const TitleTemplateSelector: React.FC = ({ + value, + onChange, + showEditor = true, + categories = ["system", "custom"], + context, +}) => { + const { + templates, + createTemplate, + duplicateTemplate, + updateTemplate, + deleteTemplate, + exportTemplate, + } = useTitleTemplates() + + const [editingTemplate, setEditingTemplate] = useState(null) + const [editorOpen, setEditorOpen] = useState(false) + + const grouped = useMemo( + () => ({ + builtin: templates.filter((t) => t.isBuiltin), + custom: templates.filter((t) => !t.isBuiltin), + }), + [templates], + ) + + const showSys = categories.includes("system") + const showMine = categories.includes("custom") + + /* ── 选中模板:合成完整 TitleStyleSettings 回调给父组件 ── */ + const handleSelectTemplate = useCallback( + (tpl: TitleTemplate) => { + const full: TitleStyleSettings = { + ...DEFAULT_TITLE_STYLE_SETTINGS, + ...titleStyleConfigToCamel(tpl.style), + } + onChange?.(tpl.id, full, tpl) + }, + [onChange], + ) + + const handleRequestCreate = useCallback(() => { + // 新建:以当前选中模板样式为起点,否则用默认样式 + let base: TitleStyleSettings = DEFAULT_TITLE_STYLE_SETTINGS + if (value) { + const sel = templates.find((t) => t.id === value) + if (sel) { + base = { ...DEFAULT_TITLE_STYLE_SETTINGS, ...titleStyleConfigToCamel(sel.style) } + } + } + const draft: TitleTemplate = { + id: "", + name: "我的标题模板", + emoji: "✨", + isBuiltin: false, + style: camelToTitleStyleConfig({ + ...base, + position: base.position === "custom" ? "bottom" : base.position, + }), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } + setEditingTemplate(draft) + setEditorOpen(true) + }, [value, templates]) + + const handleRequestEdit = useCallback((tpl: TitleTemplate) => { + setEditingTemplate(tpl) + setEditorOpen(true) + }, []) + + const handleDuplicate = useCallback( + (t: TitleTemplate) => { + const dup = duplicateTemplate(t.id) + if (dup) message.success(`已复制:${dup.name}`) + }, + [duplicateTemplate], + ) + const handleDelete = useCallback( + (t: TitleTemplate) => { + deleteTemplate(t.id) + message.success("已删除模板") + }, + [deleteTemplate], + ) + const handleExport = useCallback( + (t: TitleTemplate) => { + const json = exportTemplate(t.id) + if (!json) return + const blob = new Blob([json], { type: "application/json" }) + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = `${t.name}.title-template.json` + a.click() + URL.revokeObjectURL(url) + }, + [exportTemplate], + ) + + const handleEditorSave = useCallback( + (data: { name: string; emoji: string; style: Partial }) => { + if (!editingTemplate) return + let saved: TitleTemplate + if (editingTemplate.isBuiltin || !editingTemplate.id) { + saved = createTemplate({ name: data.name, emoji: data.emoji, style: data.style }) + } else { + updateTemplate(editingTemplate.id, { + name: data.name, + emoji: data.emoji, + style: data.style, + }) + saved = { + ...editingTemplate, + name: data.name, + emoji: data.emoji, + style: data.style, + updatedAt: new Date().toISOString(), + } + } + setEditorOpen(false) + setEditingTemplate(null) + message.success(`已保存:${data.name}`) + handleSelectTemplate(saved) + }, + [editingTemplate, createTemplate, updateTemplate, handleSelectTemplate], + ) + + /* ── 渲染单张大卡片 ── */ + const renderCard = (t: TitleTemplate, idx: number, section: "mine" | "sys") => { + const isSelected = value === t.id + const bgIdx = idx % PREVIEW_BG_IMAGES.length + const bgImg = PREVIEW_BG_IMAGES[bgIdx] + const preview = templateToPreviewSettings(t, 42) + return ( +
handleSelectTemplate(t)} + data-context={context} + > +
+ +
+ + + {section === "sys" ? "系统" : "我的"} + + + {isSelected && } + +
+
+
+ {t.emoji || "✨"} + + {t.name} + + + {section === "sys" ? "系统" : "我的"} + +
+ {showEditor && ( +
e.stopPropagation()}> + + + + handleDelete(t)}> + + +
+ )} +
+
+ ) + } + + return ( +
+
+ 标题模板 + {showEditor && ( + + )} +
+ + {showMine && ( +
+
我的模板
+ {grouped.custom.length === 0 ? ( +
+
✨
+
还没有自定义模板,点右上角「新建模板」创建
+
+ ) : ( +
+ {grouped.custom.map((t, i) => renderCard(t, i, "mine"))} +
+ )} +
+ )} + + {showSys && ( +
+
系统模板
+
{grouped.builtin.map((t, i) => renderCard(t, i, "sys"))}
+
+ )} + + {showEditor && editorOpen && editingTemplate && ( + { + setEditorOpen(false) + setEditingTemplate(null) + }} + onSave={handleEditorSave} + /> + )} +
+ ) +} + +export default TitleTemplateSelector diff --git a/apps/web/src/components/title/index.ts b/apps/web/src/components/title/index.ts new file mode 100644 index 000000000..9c53c2d54 --- /dev/null +++ b/apps/web/src/components/title/index.ts @@ -0,0 +1,19 @@ +/** + * 公共标题模板/样式组件统一导出 + * + * 任何页面需要标题样式配置/模板选择/模板编辑,从这里 import, + * 不要直接 import pages/generate/components/title/* 下的内部组件。 + */ +export { default as TitleTemplateSelector } from "./TitleTemplateSelector" +export { default as TitleTemplateEditor } from "./TitleTemplateEditor" +export { default as TitleStyleParamsTab } from "./TitleStyleParamsTab" +export { default as TitleMiniPreview } from "./TitleMiniPreview" +export { useTitleTemplates } from "./useTitleTemplates" +export * from "./constants" +export * from "./types" +export * from "./template-types" +export * from "./settings" +export * from "./utils" +export { POSITION_OPTIONS } from "./position-options" +export type { PositionOption, FontOption, TitleStyleParamsTabProps } from "./TitleStyleParamsTab" +export type { TitleTemplateSelectorProps } from "./TitleTemplateSelector" diff --git a/apps/web/src/components/title/position-options.ts b/apps/web/src/components/title/position-options.ts new file mode 100644 index 000000000..e43c982b3 --- /dev/null +++ b/apps/web/src/components/title/position-options.ts @@ -0,0 +1,14 @@ +/** + * 标题位置选项(公共常量) + */ +export interface PositionOption { + value: string + label: string +} + +export const POSITION_OPTIONS: PositionOption[] = [ + { value: "top", label: "顶部" }, + { value: "center", label: "居中" }, + { value: "bottom", label: "底部" }, + { value: "custom", label: "自定义" }, +] diff --git a/apps/web/src/components/title/settings.ts b/apps/web/src/components/title/settings.ts new file mode 100644 index 000000000..9b194203a --- /dev/null +++ b/apps/web/src/components/title/settings.ts @@ -0,0 +1,64 @@ +/** + * 标题样式设置 — 公共 camelCase 类型与默认值 + * + * 本文件是 @/components/title 公共包的唯一样式类型出口,不依赖任何业务页面(generate/ai-avatar)的私有类型。 + * - 字段与后端 snake_case TitleStyleConfig 一一对应(camelCase 版本) + * - DEFAULT_TITLE_STYLE_SETTINGS 用于组件内部补全默认值 + * - aiAutoSelect / title / coverTitle 等业务状态不在本类型中——它们属于页面业务 state + */ +import type { TitleLineOverride } from "./types" + +export interface TitleStyleSettings { + position: string + font: string + size: number + bold: boolean + italic: boolean + stroke: boolean + shadow: boolean + color: string + posX: number | null + posY: number | null + lineHeight: number + marginTop: number + maxCharsPerLine: number + strokeWidth: number + strokeColor: string + shadowOffsetX: number + shadowOffsetY: number + shadowBlur: number + shadowColor: string + bgEnabled: boolean + bgColor: string + bgPadding: number + bgRadius: number + lineOverrides: TitleLineOverride[] +} + +/** 公共默认样式(经典白字黑描边) */ +export const DEFAULT_TITLE_STYLE_SETTINGS: TitleStyleSettings = { + position: "bottom", + font: "思源黑体", + size: 56, + bold: true, + italic: false, + stroke: true, + shadow: false, + color: "#ffffff", + posX: null, + posY: null, + lineHeight: 1.2, + marginTop: 24, + maxCharsPerLine: 10, + strokeWidth: 5, + strokeColor: "#000000", + shadowOffsetX: 2, + shadowOffsetY: 2, + shadowBlur: 4, + shadowColor: "rgba(0,0,0,0.8)", + bgEnabled: false, + bgColor: "rgba(0,0,0,0.5)", + bgPadding: 12, + bgRadius: 8, + lineOverrides: [], +} diff --git a/apps/web/src/components/title/utils.ts b/apps/web/src/components/title/utils.ts index 8a9817da8..95c25df2f 100644 --- a/apps/web/src/components/title/utils.ts +++ b/apps/web/src/components/title/utils.ts @@ -1,24 +1,25 @@ /** * 标题样式工具(#2001 / 模板系统 #2003) * - * - snake_case TitleStyleConfig ↔ camelCase TitleSettings 互转 + * - snake_case TitleStyleConfig <-> camelCase TitleStyleSettings 互转 * - preset 归一化预览(修复"标题"两字大小不一) + * - template -> preview settings 转换 */ import type { TitleStyleConfig } from "./types" -import type { TitleSettings } from "../../pages/generate/types" +import type { TitleStyleSettings } from "./settings" +import { DEFAULT_TITLE_STYLE_SETTINGS } from "./settings" import { TITLE_PRESETS } from "./constants" -import { DEFAULT_TITLE_SETTINGS_FULL } from "../../pages/generate/types" import type { TitleTemplate } from "./template-types" -/** snake_case TitleStyleConfig → camelCase TitleSettings(仅覆盖已知字段) */ -export function titleStyleConfigToCamel(s: Partial): Partial { - const out: Partial = {} +/** snake_case TitleStyleConfig -> camelCase TitleStyleSettings(仅覆盖已知字段) */ +export function titleStyleConfigToCamel(s: Partial): Partial { + const out: Partial = {} if (s.font != null) out.font = s.font if (s.size != null) out.size = s.size if (s.color != null) out.color = s.color if (s.bold != null) out.bold = s.bold if (s.italic != null) out.italic = s.italic - if (s.position != null) out.position = s.position as TitleSettings["position"] + if (s.position != null) out.position = s.position if (s.pos_x != null) out.posX = s.pos_x if (s.pos_y != null) out.posY = s.pos_y if (s.line_height != null) out.lineHeight = s.line_height @@ -40,8 +41,8 @@ export function titleStyleConfigToCamel(s: Partial): Partial): Partial { +/** camelCase TitleStyleSettings patch -> snake_case TitleStyleConfig patch */ +export function camelToTitleStyleConfig(p: Partial): Partial { const out: Partial = {} if (p.font != null) out.font = p.font if (p.size != null) out.size = p.size @@ -71,15 +72,15 @@ export function camelToTitleStyleConfig(p: Partial): Partial p.key === presetKey) if (!preset) return base const origSize = preset.style.size ?? fixedSize @@ -87,25 +88,25 @@ export function buildPresetPreviewSettings( const scale = (v: number | undefined, fallback: number): number => v != null ? Math.round(v * ratio) : fallback return { + ...DEFAULT_TITLE_STYLE_SETTINGS, ...base, ...titleStyleConfigToCamel(preset.style), size: fixedSize, - strokeWidth: scale(preset.style.stroke_width, base.strokeWidth) ?? base.strokeWidth, - shadowOffsetX: scale(preset.style.shadow_offset_x, base.shadowOffsetX) ?? base.shadowOffsetX, - shadowOffsetY: scale(preset.style.shadow_offset_y, base.shadowOffsetY) ?? base.shadowOffsetY, - shadowBlur: scale(preset.style.shadow_blur, base.shadowBlur) ?? base.shadowBlur, - bgPadding: scale(preset.style.bg_padding, base.bgPadding) ?? base.bgPadding, + strokeWidth: scale(preset.style.stroke_width, base.strokeWidth), + shadowOffsetX: scale(preset.style.shadow_offset_x, base.shadowOffsetX), + shadowOffsetY: scale(preset.style.shadow_offset_y, base.shadowOffsetY), + shadowBlur: scale(preset.style.shadow_blur, base.shadowBlur), + bgPadding: scale(preset.style.bg_padding, base.bgPadding), lineOverrides: [], } } /** - * 把 TitleTemplate 渲染为完整 TitleSettings(带默认值),用于卡片预览。 - * 与模板选择器中保持一致,抽出共用。 + * 把 TitleTemplate 渲染为完整 TitleStyleSettings(带默认值),用于卡片预览。 */ -export function templateToPreviewSettings(t: TitleTemplate, fixedSize = 48): TitleSettings { - const base: TitleSettings = { - ...DEFAULT_TITLE_SETTINGS_FULL, +export function templateToPreviewSettings(t: TitleTemplate, fixedSize = 48): TitleStyleSettings { + const base: TitleStyleSettings = { + ...DEFAULT_TITLE_STYLE_SETTINGS, ...titleStyleConfigToCamel(t.style), } // 预览时用固定字号保证所有卡片字大小一致;描边/阴影/padding按比例缩放 diff --git a/apps/web/src/pages/ai-avatar/components/PanelTitleConfig.tsx b/apps/web/src/pages/ai-avatar/components/PanelTitleConfig.tsx index f02b66a5a..9eed01c6a 100644 --- a/apps/web/src/pages/ai-avatar/components/PanelTitleConfig.tsx +++ b/apps/web/src/pages/ai-avatar/components/PanelTitleConfig.tsx @@ -412,7 +412,7 @@ const PanelTitleConfig: React.FC = ({ titleConfig, onUpda onUpdateStyle={handleUpdateStyle} showCoverToggle previewWidth={280} - enableTemplates={false} + enableTemplates={true} selectedTemplateId={selectedTemplateId} onApplyTemplate={handleApplyTemplate} activePreset={activePreset} diff --git a/apps/web/src/pages/generate/components/title/TitleMiniPreview.tsx b/apps/web/src/pages/generate/components/title/TitleMiniPreview.tsx index ee5783b15..fc9f6f6ca 100644 --- a/apps/web/src/pages/generate/components/title/TitleMiniPreview.tsx +++ b/apps/web/src/pages/generate/components/title/TitleMiniPreview.tsx @@ -1,271 +1 @@ -/** - * 标题迷你 Canvas 预览(#2001) - * - * 渲染一张指定宽度的小 Canvas 预览标题效果,用于: - * - 预设卡片缩略图 - * - 样式面板顶部的实时预览 - * - * 与 titleCanvas.ts 渲染逻辑保持一致,但: - * - 固定分辨率(width × 宽高比约 2:1) - * - 不调用 ffmpeg,只做视觉预览 - * - 支持背景色块、描边宽度/颜色、阴影参数化、行距、自动换行 - */ -import React, { useEffect, useRef } from "react" -import type { TitleSettings } from "../../types" -import { getFontFamily } from "@/components/title/constants" - -interface Props { - settings: TitleSettings - width?: number - sampleText?: string - /** 背景(预览用,默认深色渐变模拟视频底),transparent=true 时忽略 */ - background?: string - /** 高度(可选,默认按 portrait 选比例) */ - height?: number - /** 透明背景(卡片/编辑器预览叠加在图片上时使用) */ - transparent?: boolean - /** 纵向竖屏预览(9:16),true 时 aspect=16/9 适配手机视频比例 */ - portrait?: boolean -} - -/** 按 maxCharsPerLine 自动换行 */ -function wrapLines(text: string, maxChars: number): string[] { - const manual = text - .split(/[//\n]/) - .map((l) => l.trim()) - .filter(Boolean) - if (!maxChars || maxChars <= 0) return manual - const out: string[] = [] - for (const line of manual) { - if (line.length <= maxChars) { - out.push(line) - continue - } - let cur = "" - for (const ch of line) { - cur += ch - if (cur.length >= maxChars) { - out.push(cur) - cur = "" - } - } - if (cur) out.push(cur) - } - return out -} - -const TitleMiniPreview: React.FC = ({ - settings, - width = 200, - sampleText, - background = "linear-gradient(135deg,#1f2937,#111827)", - height, - transparent = false, - portrait = false, -}) => { - const canvasRef = useRef(null) - const h = height ?? Math.round(width * (portrait ? 16 / 9 : 1 / 1.8)) - const text = (sampleText || settings.title || "预览标题").trim() || "预览标题" - - useEffect(() => { - let cancelled = false - const draw = () => { - if (cancelled) return - const cvs = canvasRef.current - if (!cvs) return - const dpr = window.devicePixelRatio || 1 - cvs.width = width * dpr - cvs.height = h * dpr - cvs.style.width = `${width}px` - cvs.style.height = `${h}px` - const ctx = cvs.getContext("2d") - if (!ctx) return - ctx.scale(dpr, dpr) - ctx.clearRect(0, 0, width, h) - - // 背景(transparent 时跳过,用于叠加在图片上) - if (!transparent) { - ctx.fillStyle = "#111827" - ctx.fillRect(0, 0, width, h) - } - - // 分辨率缩放:以 360 宽为基准(对应 720p 的一半),与外层 previewScale/previewR 保持一致 - const r = previewR - - // 字体 - const size = r(settings.size) - const ff = getFontFamily(settings.font) - const parts: string[] = [] - if (settings.italic) parts.push("italic") - if (settings.bold) parts.push("bold") - parts.push(`${size}px`, ff) - ctx.font = parts.join(" ") - ctx.textAlign = "center" - ctx.textBaseline = "middle" - ctx.fillStyle = settings.color - ctx.lineJoin = "round" - - // 阴影 - const shadowEnabled = !!settings.shadow - const prevShadow = { - c: ctx.shadowColor, - b: ctx.shadowBlur, - ox: ctx.shadowOffsetX, - oy: ctx.shadowOffsetY, - } - if (shadowEnabled) { - ctx.shadowColor = settings.shadowColor ?? "rgba(0,0,0,0.8)" - ctx.shadowBlur = r(settings.shadowBlur ?? 4) - ctx.shadowOffsetX = r(settings.shadowOffsetX ?? 2) - ctx.shadowOffsetY = r(settings.shadowOffsetY ?? 2) - } - - // 换行 - const lines = wrapLines(text, settings.maxCharsPerLine ?? 0) - const lineH = size * (settings.lineHeight ?? 1.2) - const totalH = lines.length * lineH - let startY: number - if (settings.position === "top") { - startY = size / 2 + r(settings.marginTop ?? 24) - } else if (settings.position === "center") { - startY = h / 2 - totalH / 2 + size / 2 - } else { - // bottom - const botMargin = portrait ? r(24) : r(16) - startY = h - totalH - botMargin + size / 2 - } - let centerX = width / 2 - if (settings.position === "custom" && settings.posX != null) { - centerX = (settings.posX / 100) * width - } - - // 背景块 - if (settings.bgEnabled) { - const pad = r(settings.bgPadding ?? 12) - const rad = r(settings.bgRadius ?? 8) - let maxLineW = 0 - for (const l of lines) { - const m = ctx.measureText(l) - if (m.width > maxLineW) maxLineW = m.width - } - const bw = maxLineW + pad * 2 - const bh = totalH + pad * 2 - const bx = centerX - bw / 2 - const by = startY - size / 2 - pad + (size - lineH) / 2 - ctx.shadowColor = "rgba(0,0,0,0)" - ctx.shadowBlur = 0 - ctx.fillStyle = settings.bgColor ?? "rgba(0,0,0,0.5)" - roundRect(ctx, bx, by, bw, bh, rad) - ctx.fill() - // 关键修复:画完背景块后必须把 fillStyle 重置为文字颜色, - // 否则后续 fillText 会用 bgColor 填充文字,导致「文字看不见只剩色块」 - ctx.fillStyle = settings.color - // 恢复阴影 - if (shadowEnabled) { - ctx.shadowColor = settings.shadowColor ?? "rgba(0,0,0,0.8)" - ctx.shadowBlur = r(settings.shadowBlur ?? 4) - ctx.shadowOffsetX = r(settings.shadowOffsetX ?? 2) - ctx.shadowOffsetY = r(settings.shadowOffsetY ?? 2) - } - } - - // 描边(先画,再画填充) - const strokeEnabled = !!settings.stroke && (settings.strokeWidth ?? 0) > 0 - lines.forEach((line, i) => { - const y = startY + i * lineH - if (strokeEnabled) { - ctx.shadowColor = "rgba(0,0,0,0)" - ctx.shadowBlur = 0 - ctx.lineWidth = r(settings.strokeWidth ?? 4) - ctx.strokeStyle = settings.strokeColor ?? "#000000" - ctx.strokeText(line, centerX, y) - // 恢复阴影 - if (shadowEnabled) { - ctx.shadowColor = settings.shadowColor ?? "rgba(0,0,0,0.8)" - ctx.shadowBlur = r(settings.shadowBlur ?? 4) - ctx.shadowOffsetX = r(settings.shadowOffsetX ?? 2) - ctx.shadowOffsetY = r(settings.shadowOffsetY ?? 2) - } - } - ctx.fillText(line, centerX, y) - }) - - // 恢复 - ctx.shadowColor = prevShadow.c - ctx.shadowBlur = prevShadow.b - ctx.shadowOffsetX = prevShadow.ox - ctx.shadowOffsetY = prevShadow.oy - } - // 计算当前字号(draw() 内部同样逻辑,抽出来供 fontString 复用) - const previewScale = width / 360 - const previewR = (v: number) => Math.round(v * previewScale) - const buildFontString = () => { - const size = previewR(settings.size) - const ff = getFontFamily(settings.font) - const parts: string[] = [] - if (settings.italic) parts.push("italic") - if (settings.bold) parts.push("bold") - parts.push(`${size}px`, ff) - return parts.join(" ") - } - - // Web Font 加载保障: - // 1) 等 document.fonts.ready(CSS @font-face 首次可用) - // 2) 显式 FontFaceSet.load(fontString, text) 触发浏览器真正下载并加载 - // 当前字体到 Canvas 可用,避免首次绘制用 fallback 字体画出错字/色块 - const doDrawWhenReady = async () => { - try { - if (typeof document !== "undefined" && document.fonts) { - await document.fonts.ready - try { - await document.fonts.load(buildFontString(), text) - } catch { - /* ignore */ - } - } - } finally { - if (!cancelled) draw() - } - } - doDrawWhenReady() - return () => { - cancelled = true - } - }, [settings, width, h, text, transparent, portrait, background]) - - return ( - - ) -} - -function roundRect( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - w: number, - h: number, - r: number, -) { - const rr = Math.min(r, w / 2, h / 2) - ctx.beginPath() - ctx.moveTo(x + rr, y) - ctx.lineTo(x + w - rr, y) - ctx.quadraticCurveTo(x + w, y, x + w, y + rr) - ctx.lineTo(x + w, y + h - rr) - ctx.quadraticCurveTo(x + w, y + h, x + w - rr, y + h) - ctx.lineTo(x + rr, y + h) - ctx.quadraticCurveTo(x, y + h, x, y + h - rr) - ctx.lineTo(x, y + rr) - ctx.quadraticCurveTo(x, y, x + rr, y) - ctx.closePath() -} - -export default TitleMiniPreview +export { default } from "@/components/title/TitleMiniPreview" diff --git a/apps/web/src/pages/generate/components/title/TitleStylePanel.tsx b/apps/web/src/pages/generate/components/title/TitleStylePanel.tsx index 9bd2466d1..de709199a 100644 --- a/apps/web/src/pages/generate/components/title/TitleStylePanel.tsx +++ b/apps/web/src/pages/generate/components/title/TitleStylePanel.tsx @@ -31,7 +31,7 @@ import { } from "@/components/title/constants" import { buildPresetPreviewSettings } from "@/components/title/utils" -import TitleMiniPreview from "./TitleMiniPreview" +import TitleMiniPreview from "@/components/title/TitleMiniPreview" import TitleTemplateEditor from "@/components/title/TitleTemplateEditor" import { useTitleTemplates } from "@/components/title/useTitleTemplates" import { @@ -177,7 +177,7 @@ const ColorPicker: React.FC<{ /* ── 卡片预览:用 ref 测量容器宽度后再渲染透明 Canvas,保证文字清晰 ── */ const FillPreview: React.FC<{ - settings: TitleSettings + settings: import("@/components/title/settings").TitleStyleSettings sampleText: string portrait?: boolean }> = ({ settings, sampleText, portrait }) => { diff --git a/apps/web/src/pages/generate/constants.ts b/apps/web/src/pages/generate/constants.ts index fd4aa51c4..1e17a4c25 100644 --- a/apps/web/src/pages/generate/constants.ts +++ b/apps/web/src/pages/generate/constants.ts @@ -46,13 +46,9 @@ export const CLIP_COUNT_STEP = 1 export const MAX_PREVIEW_COUNT = 10 export const MIN_PREVIEW_COUNT = 1 -/* ── 标题位置选项 ── */ -export const POSITION_OPTIONS = [ - { value: "top", label: "顶部" }, - { value: "center", label: "居中" }, - { value: "bottom", label: "底部" }, - { value: "custom", label: "自定义" }, -] +/* ── 标题位置选项(统一从公共层重导出) ── */ +export { POSITION_OPTIONS } from "@/components/title/position-options" +export type { PositionOption } from "@/components/title/position-options" /* ── 标题字体:统一使用公共层定义(#2001) ── */ export { getFontFamily } from "@/components/title/constants"