910625d5e1
新增 file.exportImage 宿主能力契约 分享卡下载在原生壳中优先走宿主图片导出 Expo 壳写入缓存图片并调用系统分享保存 Tauri 壳通过保存对话框写入图片字节 补齐能力漂移检查、测试和架构文档
436 lines
11 KiB
TypeScript
436 lines
11 KiB
TypeScript
import {
|
|
readAssetBytes,
|
|
shouldResolveAssetReadUrl,
|
|
} from '../../services/assetReadUrlService';
|
|
import { exportHostImageFile } from '../../services/host-bridge/hostBridge';
|
|
import {
|
|
buildPublishShareCardFileName,
|
|
type PublishShareModalPayload,
|
|
} from './publishShareModalModel';
|
|
|
|
const CARD_WIDTH = 1080;
|
|
const CARD_HEIGHT = 1440;
|
|
const CARD_RADIUS = 24;
|
|
const COVER_X = 0;
|
|
const COVER_Y = 0;
|
|
const COVER_SIZE = CARD_WIDTH;
|
|
const CONTENT_PADDING_X = 48;
|
|
const CONTENT_TOP = COVER_Y + COVER_SIZE + 48;
|
|
const TYPE_PILL_HEIGHT = 64;
|
|
|
|
type PublishShareCardTheme = {
|
|
background: string;
|
|
border: string;
|
|
neutralBackground: string;
|
|
accentText: string;
|
|
titleText: string;
|
|
mutedText: string;
|
|
};
|
|
|
|
function resolveCssColor(variableName: string, fallback: string) {
|
|
if (typeof document === 'undefined') {
|
|
return fallback;
|
|
}
|
|
|
|
const value = getComputedStyle(document.documentElement)
|
|
.getPropertyValue(variableName)
|
|
.trim();
|
|
return value || fallback;
|
|
}
|
|
|
|
function resolvePublishShareCardTheme(): PublishShareCardTheme {
|
|
return {
|
|
background: '#fffaf4',
|
|
border: resolveCssColor('--platform-subpanel-border', '#ead9c7'),
|
|
neutralBackground: resolveCssColor('--platform-neutral-bg', '#f2e3d5'),
|
|
accentText: resolveCssColor('--platform-accent-text', '#7f5539'),
|
|
titleText: resolveCssColor('--platform-text-strong', '#332820'),
|
|
mutedText: resolveCssColor('--platform-text-muted', '#a88e7c'),
|
|
};
|
|
}
|
|
|
|
function drawRoundedRect(
|
|
context: CanvasRenderingContext2D,
|
|
x: number,
|
|
y: number,
|
|
width: number,
|
|
height: number,
|
|
radius: number,
|
|
) {
|
|
context.beginPath();
|
|
context.moveTo(x + radius, y);
|
|
context.lineTo(x + width - radius, y);
|
|
context.quadraticCurveTo(x + width, y, x + width, y + radius);
|
|
context.lineTo(x + width, y + height - radius);
|
|
context.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
|
|
context.lineTo(x + radius, y + height);
|
|
context.quadraticCurveTo(x, y + height, x, y + height - radius);
|
|
context.lineTo(x, y + radius);
|
|
context.quadraticCurveTo(x, y, x + radius, y);
|
|
context.closePath();
|
|
}
|
|
|
|
function drawWrappedText(
|
|
context: CanvasRenderingContext2D,
|
|
text: string,
|
|
x: number,
|
|
y: number,
|
|
maxWidth: number,
|
|
lineHeight: number,
|
|
maxLines: number,
|
|
) {
|
|
const chars = Array.from(text.trim() || '我的作品');
|
|
const lines: string[] = [];
|
|
let currentLine = '';
|
|
|
|
for (const char of chars) {
|
|
const nextLine = `${currentLine}${char}`;
|
|
if (currentLine && context.measureText(nextLine).width > maxWidth) {
|
|
lines.push(currentLine);
|
|
currentLine = char;
|
|
if (lines.length >= maxLines) {
|
|
break;
|
|
}
|
|
} else {
|
|
currentLine = nextLine;
|
|
}
|
|
}
|
|
|
|
if (lines.length < maxLines && currentLine) {
|
|
lines.push(currentLine);
|
|
}
|
|
|
|
lines.slice(0, maxLines).forEach((line, index) => {
|
|
const isLast = index === maxLines - 1 && lines.length >= maxLines;
|
|
let displayLine = line;
|
|
while (
|
|
isLast &&
|
|
displayLine.length > 1 &&
|
|
context.measureText(`${displayLine}...`).width > maxWidth
|
|
) {
|
|
displayLine = displayLine.slice(0, -1);
|
|
}
|
|
context.fillText(isLast ? `${displayLine}...` : displayLine, x, y + index * lineHeight);
|
|
});
|
|
}
|
|
|
|
function shouldLoadImageWithAnonymousCors(src: string) {
|
|
if (
|
|
src.startsWith('data:') ||
|
|
src.startsWith('blob:') ||
|
|
typeof window === 'undefined'
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const parsedUrl = new URL(src, window.location.origin);
|
|
return (
|
|
/^https?:$/u.test(parsedUrl.protocol) &&
|
|
parsedUrl.origin !== window.location.origin
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export async function resolvePublishShareCardCanvasImageSource(src: string) {
|
|
const normalizedSrc = src.trim();
|
|
if (!normalizedSrc) {
|
|
return {
|
|
src: '',
|
|
release() {},
|
|
};
|
|
}
|
|
|
|
if (!shouldResolveAssetReadUrl(normalizedSrc)) {
|
|
return {
|
|
src: normalizedSrc,
|
|
release() {},
|
|
};
|
|
}
|
|
|
|
const response = await readAssetBytes(normalizedSrc, {
|
|
expireSeconds: 600,
|
|
});
|
|
const blob = await response.blob();
|
|
const objectUrl = URL.createObjectURL(blob);
|
|
|
|
return {
|
|
src: objectUrl,
|
|
release() {
|
|
URL.revokeObjectURL(objectUrl);
|
|
},
|
|
};
|
|
}
|
|
|
|
function loadCanvasImage(src: string) {
|
|
return new Promise<HTMLImageElement>((resolve, reject) => {
|
|
const image = new Image();
|
|
if (shouldLoadImageWithAnonymousCors(src)) {
|
|
image.crossOrigin = 'anonymous';
|
|
}
|
|
image.onload = () => resolve(image);
|
|
image.onerror = () => reject(new Error('分享卡封面加载失败'));
|
|
image.src = src;
|
|
});
|
|
}
|
|
|
|
function drawImageCover(
|
|
context: CanvasRenderingContext2D,
|
|
image: HTMLImageElement,
|
|
x: number,
|
|
y: number,
|
|
width: number,
|
|
height: number,
|
|
) {
|
|
const sourceWidth = image.naturalWidth || image.width;
|
|
const sourceHeight = image.naturalHeight || image.height;
|
|
const sourceRatio = sourceWidth / Math.max(1, sourceHeight);
|
|
const targetRatio = width / Math.max(1, height);
|
|
const cropWidth = sourceRatio > targetRatio ? sourceHeight * targetRatio : sourceWidth;
|
|
const cropHeight = sourceRatio > targetRatio ? sourceHeight : sourceWidth / targetRatio;
|
|
const cropX = (sourceWidth - cropWidth) / 2;
|
|
const cropY = (sourceHeight - cropHeight) / 2;
|
|
|
|
context.drawImage(
|
|
image,
|
|
cropX,
|
|
cropY,
|
|
cropWidth,
|
|
cropHeight,
|
|
x,
|
|
y,
|
|
width,
|
|
height,
|
|
);
|
|
}
|
|
|
|
function drawCoverFallback(
|
|
context: CanvasRenderingContext2D,
|
|
payload: PublishShareModalPayload,
|
|
) {
|
|
const gradient = context.createLinearGradient(
|
|
COVER_X,
|
|
COVER_Y,
|
|
COVER_X + COVER_SIZE,
|
|
COVER_Y + COVER_SIZE,
|
|
);
|
|
gradient.addColorStop(0, '#f6c58d');
|
|
gradient.addColorStop(0.48, '#e7b7b7');
|
|
gradient.addColorStop(1, '#9bbfd1');
|
|
context.fillStyle = gradient;
|
|
context.fillRect(COVER_X, COVER_Y, COVER_SIZE, COVER_SIZE);
|
|
|
|
context.fillStyle = 'rgba(255, 255, 255, 0.82)';
|
|
context.font = '900 156px sans-serif';
|
|
context.textAlign = 'center';
|
|
context.textBaseline = 'middle';
|
|
const initial = Array.from(payload.title.trim() || '陶')[0] ?? '陶';
|
|
context.fillText(initial, CARD_WIDTH / 2, COVER_Y + COVER_SIZE / 2);
|
|
}
|
|
|
|
function drawCopyIcon(
|
|
context: CanvasRenderingContext2D,
|
|
x: number,
|
|
y: number,
|
|
color: string,
|
|
) {
|
|
context.strokeStyle = color;
|
|
context.lineWidth = 4;
|
|
drawRoundedRect(context, x + 10, y, 24, 30, 4);
|
|
context.stroke();
|
|
drawRoundedRect(context, x, y + 10, 24, 30, 4);
|
|
context.stroke();
|
|
}
|
|
|
|
async function drawShareCard(
|
|
context: CanvasRenderingContext2D,
|
|
payload: PublishShareModalPayload,
|
|
coverImageSrc: string,
|
|
) {
|
|
const theme = resolvePublishShareCardTheme();
|
|
context.clearRect(0, 0, CARD_WIDTH, CARD_HEIGHT);
|
|
|
|
context.save();
|
|
drawRoundedRect(context, 0, 0, CARD_WIDTH, CARD_HEIGHT, CARD_RADIUS);
|
|
context.clip();
|
|
|
|
context.fillStyle = theme.background;
|
|
context.fillRect(0, 0, CARD_WIDTH, CARD_HEIGHT);
|
|
|
|
if (coverImageSrc) {
|
|
const canvasImageSource =
|
|
await resolvePublishShareCardCanvasImageSource(coverImageSrc);
|
|
try {
|
|
const image = await loadCanvasImage(canvasImageSource.src);
|
|
drawImageCover(context, image, COVER_X, COVER_Y, COVER_SIZE, COVER_SIZE);
|
|
} finally {
|
|
canvasImageSource.release();
|
|
}
|
|
} else {
|
|
drawCoverFallback(context, payload);
|
|
}
|
|
|
|
const typeLabel = payload.workTypeLabel?.trim() || '互动作品';
|
|
context.font = '800 36px sans-serif';
|
|
const pillWidth = Math.min(
|
|
CARD_WIDTH - CONTENT_PADDING_X * 2,
|
|
Math.max(180, context.measureText(typeLabel).width + 72),
|
|
);
|
|
const pillY = CONTENT_TOP;
|
|
context.fillStyle = theme.neutralBackground;
|
|
drawRoundedRect(
|
|
context,
|
|
CONTENT_PADDING_X,
|
|
pillY,
|
|
pillWidth,
|
|
TYPE_PILL_HEIGHT,
|
|
TYPE_PILL_HEIGHT / 2,
|
|
);
|
|
context.fill();
|
|
context.fillStyle = theme.accentText;
|
|
context.textAlign = 'left';
|
|
context.textBaseline = 'middle';
|
|
context.fillText(typeLabel, CONTENT_PADDING_X + 36, pillY + TYPE_PILL_HEIGHT / 2);
|
|
|
|
context.fillStyle = theme.titleText;
|
|
context.font = '900 72px sans-serif';
|
|
context.textBaseline = 'top';
|
|
drawWrappedText(
|
|
context,
|
|
payload.title,
|
|
CONTENT_PADDING_X,
|
|
pillY + 92,
|
|
CARD_WIDTH - CONTENT_PADDING_X * 2,
|
|
84,
|
|
2,
|
|
);
|
|
|
|
const code = payload.publicWorkCode.trim();
|
|
if (code) {
|
|
const codeY = CARD_HEIGHT - 74;
|
|
context.fillStyle = theme.mutedText;
|
|
context.font = '700 34px sans-serif';
|
|
context.textBaseline = 'middle';
|
|
drawCopyIcon(context, CONTENT_PADDING_X, codeY - 20, theme.mutedText);
|
|
context.fillText(code, CONTENT_PADDING_X + 54, codeY);
|
|
}
|
|
|
|
context.restore();
|
|
|
|
context.strokeStyle = theme.border;
|
|
context.lineWidth = 3;
|
|
drawRoundedRect(context, 1.5, 1.5, CARD_WIDTH - 3, CARD_HEIGHT - 3, CARD_RADIUS);
|
|
context.stroke();
|
|
}
|
|
|
|
function canvasToBlob(canvas: HTMLCanvasElement) {
|
|
return new Promise<Blob>((resolve, reject) => {
|
|
if (typeof canvas.toBlob !== 'function') {
|
|
try {
|
|
const dataUrl = canvas.toDataURL('image/png');
|
|
const binary = atob(dataUrl.split(',')[1] ?? '');
|
|
const bytes = new Uint8Array(binary.length);
|
|
for (let index = 0; index < binary.length; index += 1) {
|
|
bytes[index] = binary.charCodeAt(index);
|
|
}
|
|
resolve(new Blob([bytes], { type: 'image/png' }));
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
return;
|
|
}
|
|
|
|
canvas.toBlob((blob) => {
|
|
if (blob) {
|
|
resolve(blob);
|
|
} else {
|
|
reject(new Error('分享卡导出失败'));
|
|
}
|
|
}, 'image/png');
|
|
});
|
|
}
|
|
|
|
function blobToBase64Data(blob: Blob) {
|
|
return new Promise<string>((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
const result = typeof reader.result === 'string' ? reader.result : '';
|
|
const base64Data = result.split(',')[1] ?? '';
|
|
if (base64Data) {
|
|
resolve(base64Data);
|
|
} else {
|
|
reject(new Error('分享卡图片编码失败'));
|
|
}
|
|
};
|
|
reader.onerror = () => reject(new Error('分享卡图片编码失败'));
|
|
reader.readAsDataURL(blob);
|
|
});
|
|
}
|
|
|
|
async function exportShareCardBlob(blob: Blob, fileName: string) {
|
|
const exported = await exportHostImageFile({
|
|
fileName,
|
|
base64Data: await blobToBase64Data(blob),
|
|
mimeType: 'image/png',
|
|
});
|
|
|
|
if (exported) {
|
|
return;
|
|
}
|
|
|
|
triggerDownload(blob, fileName);
|
|
}
|
|
|
|
async function renderShareCardBlob(
|
|
payload: PublishShareModalPayload,
|
|
coverImageSrc: string,
|
|
) {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = CARD_WIDTH;
|
|
canvas.height = CARD_HEIGHT;
|
|
const context = canvas.getContext('2d');
|
|
if (!context) {
|
|
return null;
|
|
}
|
|
|
|
await drawShareCard(context, payload, coverImageSrc);
|
|
return await canvasToBlob(canvas);
|
|
}
|
|
|
|
function triggerDownload(blob: Blob, fileName: string) {
|
|
const objectUrl = URL.createObjectURL(blob);
|
|
const anchor = document.createElement('a');
|
|
anchor.href = objectUrl;
|
|
anchor.download = fileName;
|
|
document.body.appendChild(anchor);
|
|
anchor.click();
|
|
document.body.removeChild(anchor);
|
|
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
|
|
}
|
|
|
|
export async function downloadPublishShareCardImage(
|
|
payload: PublishShareModalPayload,
|
|
coverImageSrc: string,
|
|
) {
|
|
if (typeof document === 'undefined') {
|
|
return false;
|
|
}
|
|
|
|
let blob: Blob | null = null;
|
|
|
|
try {
|
|
blob = await renderShareCardBlob(payload, coverImageSrc);
|
|
} catch {
|
|
blob = await renderShareCardBlob(payload, '');
|
|
}
|
|
|
|
if (!blob) {
|
|
return false;
|
|
}
|
|
|
|
await exportShareCardBlob(blob, buildPublishShareCardFileName(payload));
|
|
return true;
|
|
}
|