Initial project baseline
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
import {
|
||||
CanvasDocument,
|
||||
CanvasElement,
|
||||
CanvasLayer,
|
||||
CanvasLayerFolder,
|
||||
} from '../types';
|
||||
|
||||
export const DPI = 96;
|
||||
export const MM_PER_INCH = 25.4;
|
||||
export const DEFAULT_LAYER_ID = 'layer-default';
|
||||
|
||||
export function mmToPx(mm: number) {
|
||||
return Math.max(1, Math.round((mm / MM_PER_INCH) * DPI));
|
||||
}
|
||||
|
||||
export function pxToMm(px: number) {
|
||||
return (px / DPI) * MM_PER_INCH;
|
||||
}
|
||||
|
||||
export function formatMm(px: number) {
|
||||
return pxToMm(px).toFixed(1);
|
||||
}
|
||||
|
||||
export function makeId(prefix: string) {
|
||||
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
export function createDefaultDocument(): CanvasDocument {
|
||||
return {
|
||||
width: 1600,
|
||||
height: 1000,
|
||||
background: '#ffffff',
|
||||
layers: [createDefaultLayer()],
|
||||
layerFolders: [],
|
||||
elements: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function createDefaultLayer(): CanvasLayer {
|
||||
return {
|
||||
id: DEFAULT_LAYER_ID,
|
||||
name: '图层 1',
|
||||
visible: true,
|
||||
locked: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeDocument(input: CanvasDocument): CanvasDocument {
|
||||
const baseLayer = createDefaultLayer();
|
||||
const rawLayers = Array.isArray(input.layers) && input.layers.length > 0 ? input.layers : [baseLayer];
|
||||
const seen = new Set<string>();
|
||||
const layers: CanvasLayer[] = rawLayers
|
||||
.filter(layer => layer && typeof layer.id === 'string' && layer.id)
|
||||
.map((layer, index) => {
|
||||
const id = seen.has(layer.id) ? `${layer.id}-${index}` : layer.id;
|
||||
seen.add(id);
|
||||
return {
|
||||
id,
|
||||
name: layer.name || `图层 ${index + 1}`,
|
||||
visible: layer.visible !== false,
|
||||
locked: layer.locked === true,
|
||||
folderId: layer.folderId || (layer as CanvasLayer & { groupId?: string }).groupId || undefined,
|
||||
};
|
||||
});
|
||||
|
||||
if (layers.length === 0) layers.push(baseLayer);
|
||||
if (!layers.some(layer => layer.id === DEFAULT_LAYER_ID)) {
|
||||
layers.unshift(baseLayer);
|
||||
}
|
||||
|
||||
const layerIds = new Set(layers.map(layer => layer.id));
|
||||
const fallbackLayerId = layers[0]?.id || DEFAULT_LAYER_ID;
|
||||
const elements: CanvasElement[] = (Array.isArray(input.elements) ? input.elements : []).map(element => ({
|
||||
...element,
|
||||
layerId: element.layerId && layerIds.has(element.layerId) ? element.layerId : fallbackLayerId,
|
||||
groupId: typeof element.groupId === 'string' && element.groupId.trim() ? element.groupId : undefined,
|
||||
}));
|
||||
|
||||
const layerFolders = normalizeFolders(
|
||||
input.layerFolders || (input as CanvasDocument & { layerGroups?: CanvasLayerFolder[] }).layerGroups || [],
|
||||
layers,
|
||||
);
|
||||
const folderIds = new Set(layerFolders.map(folder => folder.id));
|
||||
const nextLayers = layers.map(layer => {
|
||||
const { groupId: _legacyGroupId, ...cleanLayer } = layer as CanvasLayer & { groupId?: string };
|
||||
return {
|
||||
...cleanLayer,
|
||||
folderId: layer.folderId && folderIds.has(layer.folderId) ? layer.folderId : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
width: Number.isFinite(input.width) ? input.width : 1600,
|
||||
height: Number.isFinite(input.height) ? input.height : 1000,
|
||||
background: input.background || '#ffffff',
|
||||
layers: nextLayers,
|
||||
layerFolders,
|
||||
elements,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeFolders(folders: CanvasLayerFolder[], layers: CanvasLayer[]) {
|
||||
const layerIds = new Set(layers.map(layer => layer.id));
|
||||
return folders
|
||||
.filter(folder => folder && typeof folder.id === 'string' && folder.id)
|
||||
.map(folder => ({
|
||||
id: folder.id,
|
||||
name: folder.name || '未命名文件夹',
|
||||
layerIds: folder.layerIds.filter(layerId => layerIds.has(layerId)),
|
||||
collapsed: folder.collapsed === true,
|
||||
}));
|
||||
}
|
||||
|
||||
export function layerIsVisible(documentModel: CanvasDocument, layerId?: string) {
|
||||
const normalized = normalizeDocument(documentModel);
|
||||
const layer = normalized.layers?.find(item => item.id === layerId);
|
||||
return !layer || layer.visible !== false;
|
||||
}
|
||||
|
||||
export function layerIsLocked(documentModel: CanvasDocument, layerId?: string) {
|
||||
const normalized = normalizeDocument(documentModel);
|
||||
const layer = normalized.layers?.find(item => item.id === layerId);
|
||||
return layer?.locked === true;
|
||||
}
|
||||
|
||||
export function getLayerFolder(documentModel: CanvasDocument, layerId?: string) {
|
||||
const normalized = normalizeDocument(documentModel);
|
||||
const layer = normalized.layers?.find(item => item.id === layerId);
|
||||
if (!layer?.folderId) return null;
|
||||
return normalized.layerFolders?.find(folder => folder.id === layer.folderId) || null;
|
||||
}
|
||||
|
||||
export function cloneDocument(documentModel: CanvasDocument): CanvasDocument {
|
||||
return normalizeDocument(JSON.parse(JSON.stringify(documentModel)) as CanvasDocument);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { StickerAsset } from '../types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backend-based sticker library
|
||||
// All sticker file content is stored on the backend via /api/assets.
|
||||
// Only tiny non-content metadata (tint) is kept in localStorage.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const STICKER_TINTS_KEY = 'wordcloud-sticker-tints';
|
||||
const STICKER_LIBRARY_EVENT = 'wordcloud-sticker-library-changed';
|
||||
|
||||
export const stickerLibraryEventName = STICKER_LIBRARY_EVENT;
|
||||
|
||||
// ── tint metadata helpers ──────────────────────────────────────────────────
|
||||
|
||||
function loadTints(): Record<string, string> {
|
||||
try {
|
||||
const raw = localStorage.getItem(STICKER_TINTS_KEY);
|
||||
return raw ? JSON.parse(raw) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function saveTints(tints: Record<string, string>) {
|
||||
localStorage.setItem(STICKER_TINTS_KEY, JSON.stringify(tints));
|
||||
}
|
||||
|
||||
function setTint(assetId: string, tint: string | undefined) {
|
||||
const tints = loadTints();
|
||||
if (tint) {
|
||||
tints[assetId] = tint;
|
||||
} else {
|
||||
delete tints[assetId];
|
||||
}
|
||||
saveTints(tints);
|
||||
}
|
||||
|
||||
function removeTint(assetId: string) {
|
||||
const tints = loadTints();
|
||||
delete tints[assetId];
|
||||
saveTints(tints);
|
||||
}
|
||||
|
||||
// ── content helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function svgToBlob(svg: string): Blob {
|
||||
return new Blob([svg], { type: 'image/svg+xml' });
|
||||
}
|
||||
|
||||
function dataUrlToBlob(dataUrl: string): Blob {
|
||||
const [header, b64] = dataUrl.split(',');
|
||||
const mime = header.match(/:(.*?);/)?.[1] ?? 'application/octet-stream';
|
||||
const binary = atob(b64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return new Blob([bytes], { type: mime });
|
||||
}
|
||||
|
||||
function extForType(type: 'svg' | 'image', source: string): string {
|
||||
if (type === 'svg') return '.svg';
|
||||
if (source.startsWith('data:image/png')) return '.png';
|
||||
if (source.startsWith('data:image/jpeg') || source.startsWith('data:image/jpg')) return '.jpg';
|
||||
return '.png';
|
||||
}
|
||||
|
||||
// ── API helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
interface BackendAsset {
|
||||
asset_id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
mime_type: string;
|
||||
file_url: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
async function apiListAssets(): Promise<BackendAsset[]> {
|
||||
const res = await fetch('/api/assets?type=sticker');
|
||||
if (!res.ok) throw new Error(`list assets failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function apiUploadAsset(
|
||||
blob: Blob,
|
||||
filename: string,
|
||||
name: string,
|
||||
): Promise<BackendAsset> {
|
||||
const form = new FormData();
|
||||
form.append('file', blob, filename);
|
||||
form.append('name', name);
|
||||
form.append('type', 'sticker');
|
||||
const res = await fetch('/api/assets', { method: 'POST', body: form });
|
||||
if (!res.ok) throw new Error(`upload asset failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function apiDeleteAsset(assetId: string): Promise<void> {
|
||||
const res = await fetch(`/api/assets/${assetId}`, { method: 'DELETE' });
|
||||
if (!res.ok && res.status !== 404) throw new Error(`delete asset failed: ${res.status}`);
|
||||
}
|
||||
|
||||
// ── Public API ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function loadStickerLibrary(): Promise<StickerAsset[]> {
|
||||
const [assets, tints] = await Promise.all([
|
||||
apiListAssets(),
|
||||
Promise.resolve(loadTints()),
|
||||
]);
|
||||
return assets.map((a): StickerAsset => ({
|
||||
id: a.asset_id,
|
||||
name: a.name,
|
||||
type: a.mime_type === 'image/svg+xml' ? 'svg' : 'image',
|
||||
source: a.file_url,
|
||||
createdAt: a.created_at,
|
||||
tint: tints[a.asset_id] as StickerAsset['tint'],
|
||||
}));
|
||||
}
|
||||
|
||||
export async function addStickerAsset(
|
||||
input: Omit<StickerAsset, 'id' | 'createdAt'>,
|
||||
): Promise<StickerAsset> {
|
||||
const ext = extForType(input.type, input.source);
|
||||
const blob =
|
||||
input.type === 'svg'
|
||||
? svgToBlob(input.source)
|
||||
: dataUrlToBlob(input.source);
|
||||
const filename = `sticker${ext}`;
|
||||
const asset = await apiUploadAsset(blob, filename, input.name);
|
||||
if (input.tint) setTint(asset.asset_id, input.tint);
|
||||
const sticker: StickerAsset = {
|
||||
id: asset.asset_id,
|
||||
name: asset.name,
|
||||
type: input.type,
|
||||
source: asset.file_url,
|
||||
createdAt: asset.created_at,
|
||||
tint: input.tint,
|
||||
};
|
||||
window.dispatchEvent(new CustomEvent(STICKER_LIBRARY_EVENT));
|
||||
return sticker;
|
||||
}
|
||||
|
||||
export async function deleteStickerAsset(id: string): Promise<void> {
|
||||
await apiDeleteAsset(id);
|
||||
removeTint(id);
|
||||
window.dispatchEvent(new CustomEvent(STICKER_LIBRARY_EVENT));
|
||||
}
|
||||
|
||||
export function svgToDataUrl(svg: string) {
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
||||
}
|
||||
|
||||
export function assetToDataUrl(asset: StickerAsset) {
|
||||
// source is now a backend URL; return it directly
|
||||
return asset.source;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { CanvasDocument, StickerAsset } from '../types';
|
||||
import { normalizeDocument, pxToMm } from './canvasDocument';
|
||||
import { createZip } from './zip';
|
||||
|
||||
export interface SerializeOptions {
|
||||
layerIds?: string[];
|
||||
includeBackground?: boolean;
|
||||
}
|
||||
|
||||
async function fetchBlobAsDataUrl(url: string): Promise<string> {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`fetch failed: ${url} (${res.status})`);
|
||||
const blob = await res.blob();
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveStickerHref(asset: StickerAsset): Promise<string> {
|
||||
// Legacy inline content (still supported for imported files / tests)
|
||||
if (asset.type === 'svg' && asset.source.trim().startsWith('<svg')) {
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(asset.source)}`;
|
||||
}
|
||||
if (asset.source.startsWith('data:')) {
|
||||
return asset.source;
|
||||
}
|
||||
// Backend URL: fetch and inline so the exported SVG is self-contained
|
||||
return fetchBlobAsDataUrl(asset.source);
|
||||
}
|
||||
|
||||
export async function serializeDocument(
|
||||
documentModel: CanvasDocument,
|
||||
stickerById: Map<string, StickerAsset>,
|
||||
options: SerializeOptions = {},
|
||||
) {
|
||||
const doc = normalizeDocument(documentModel);
|
||||
const layerFilter = options.layerIds ? new Set(options.layerIds) : null;
|
||||
const visibleLayers = new Set((doc.layers || []).filter(layer => layer.visible !== false).map(layer => layer.id));
|
||||
const widthMm = pxToMm(doc.width).toFixed(1);
|
||||
const heightMm = pxToMm(doc.height).toFixed(1);
|
||||
const parts = [
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${widthMm}mm" height="${heightMm}mm" viewBox="0 0 ${doc.width} ${doc.height}">`,
|
||||
];
|
||||
|
||||
if (options.includeBackground !== false) {
|
||||
parts.push(`<rect width="100%" height="100%" fill="${escapeXml(doc.background)}"/>`);
|
||||
}
|
||||
|
||||
for (const element of doc.elements) {
|
||||
const layerId = element.layerId || doc.layers?.[0]?.id;
|
||||
if (layerFilter && (!layerId || !layerFilter.has(layerId))) continue;
|
||||
if (!layerFilter && layerId && !visibleLayers.has(layerId)) continue;
|
||||
|
||||
const transform = `translate(${element.x} ${element.y}) rotate(${element.rotation} ${element.width / 2} ${element.height / 2})`;
|
||||
const opacity = Number.isFinite(element.opacity) ? element.opacity : 1;
|
||||
|
||||
if (element.type === 'sticker') {
|
||||
const asset = stickerById.get(element.assetId);
|
||||
if (!asset) continue;
|
||||
const href = await resolveStickerHref(asset);
|
||||
const filter = asset.tint === 'gray' ? ' style="filter: grayscale(1)"' : '';
|
||||
parts.push(`<image href="${escapeXml(href)}" x="0" y="0" width="${element.width}" height="${element.height}" preserveAspectRatio="xMidYMid meet" opacity="${opacity}" transform="${transform}"${filter}/>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (element.type === 'text') {
|
||||
parts.push(
|
||||
`<text x="0" y="${element.fontSize}" fill="${escapeXml(element.fill)}" font-size="${element.fontSize}" font-family="${escapeXml(element.fontFamily)}" font-weight="${escapeXml(element.fontWeight)}" opacity="${opacity}" transform="${transform}">${escapeXml(element.text)}</text>`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (element.type === 'rect') {
|
||||
parts.push(`<rect x="0" y="0" width="${element.width}" height="${element.height}" fill="${escapeXml(element.fill)}" stroke="${escapeXml(element.stroke)}" stroke-width="${element.strokeWidth}" opacity="${opacity}" transform="${transform}"/>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (element.type === 'ellipse') {
|
||||
parts.push(`<ellipse cx="${element.width / 2}" cy="${element.height / 2}" rx="${element.width / 2}" ry="${element.height / 2}" fill="${escapeXml(element.fill)}" stroke="${escapeXml(element.stroke)}" stroke-width="${element.strokeWidth}" opacity="${opacity}" transform="${transform}"/>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
parts.push(`<line x1="0" y1="${element.height / 2}" x2="${element.width}" y2="${element.height / 2}" stroke="${escapeXml(element.stroke)}" stroke-width="${element.strokeWidth}" stroke-linecap="round" opacity="${opacity}" transform="${transform}"/>`);
|
||||
}
|
||||
|
||||
parts.push('</svg>');
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
export async function createLayerExportZip(
|
||||
documentModel: CanvasDocument,
|
||||
stickerById: Map<string, StickerAsset>,
|
||||
selectedLayerIds: string[],
|
||||
selectedFolderIds: string[],
|
||||
) {
|
||||
const doc = normalizeDocument(documentModel);
|
||||
const files: { name: string; content: string }[] = [];
|
||||
const used = new Map<string, number>();
|
||||
|
||||
for (const layerId of selectedLayerIds) {
|
||||
const layer = doc.layers?.find(item => item.id === layerId);
|
||||
if (!layer) continue;
|
||||
files.push({
|
||||
name: uniqueSvgName(layer.name, used),
|
||||
content: await serializeDocument(doc, stickerById, { layerIds: [layer.id], includeBackground: false }),
|
||||
});
|
||||
}
|
||||
|
||||
for (const folderId of selectedFolderIds) {
|
||||
const folder = doc.layerFolders?.find(item => item.id === folderId);
|
||||
if (!folder) continue;
|
||||
files.push({
|
||||
name: uniqueSvgName(folder.name, used),
|
||||
content: await serializeDocument(doc, stickerById, { layerIds: folder.layerIds, includeBackground: false }),
|
||||
});
|
||||
}
|
||||
|
||||
files.push({
|
||||
name: uniqueSvgName('总效果', used),
|
||||
content: await serializeDocument(doc, stickerById, { includeBackground: true }),
|
||||
});
|
||||
|
||||
return createZip(files);
|
||||
}
|
||||
|
||||
function uniqueSvgName(name: string, used: Map<string, number>) {
|
||||
const base = sanitizeFileName(name || '未命名');
|
||||
const count = used.get(base) || 0;
|
||||
used.set(base, count + 1);
|
||||
return `${base}${count > 0 ? `-${count + 1}` : ''}.svg`;
|
||||
}
|
||||
|
||||
function sanitizeFileName(value: string) {
|
||||
return value.replace(/[\\/:*?"<>|]/g, '-').replace(/\s+/g, ' ').trim().slice(0, 80) || '未命名';
|
||||
}
|
||||
|
||||
export function escapeXml(value: string) {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { BackendAsset, CanvasDocument, CanvasTemplate } from '../types';
|
||||
import { cloneDocument, normalizeDocument } from './canvasDocument';
|
||||
|
||||
const API_BASE = '';
|
||||
|
||||
export function templateId(template: CanvasTemplate) {
|
||||
return template.template_id || template.id || '';
|
||||
}
|
||||
|
||||
export function templateCreatedAt(template: CanvasTemplate) {
|
||||
return template.created_at || template.createdAt || '';
|
||||
}
|
||||
|
||||
export function templateUpdatedAt(template: CanvasTemplate) {
|
||||
return template.updated_at || template.updatedAt || '';
|
||||
}
|
||||
|
||||
export function templateReferenceIds(template: CanvasTemplate) {
|
||||
return template.reference_asset_ids || template.referenceAssetIds || [];
|
||||
}
|
||||
|
||||
export function templateCoverId(template: CanvasTemplate) {
|
||||
return template.cover_asset_id || template.coverAssetId || templateReferenceIds(template)[0] || '';
|
||||
}
|
||||
|
||||
export async function listDesignTemplates(): Promise<CanvasTemplate[]> {
|
||||
const res = await fetch(`${API_BASE}/api/design-templates`);
|
||||
if (!res.ok) throw new Error(`读取模板库失败 (${res.status})`);
|
||||
const items = (await res.json()) as CanvasTemplate[];
|
||||
return items.map(item => ({ ...item, document: normalizeDocument(item.document) }));
|
||||
}
|
||||
|
||||
export async function createCanvasTemplate(input: {
|
||||
name: string;
|
||||
description: string;
|
||||
document: CanvasDocument;
|
||||
referenceAssetIds?: string[];
|
||||
coverAssetId?: string;
|
||||
}): Promise<CanvasTemplate> {
|
||||
const fd = new FormData();
|
||||
fd.append('name', input.name.trim() || '未命名模板');
|
||||
fd.append('description', input.description.trim());
|
||||
fd.append('document', JSON.stringify(normalizeDocument(input.document)));
|
||||
fd.append('reference_asset_ids', JSON.stringify(input.referenceAssetIds || []));
|
||||
fd.append('cover_asset_id', input.coverAssetId || input.referenceAssetIds?.[0] || '');
|
||||
const res = await fetch(`${API_BASE}/api/design-templates`, { method: 'POST', body: fd });
|
||||
if (!res.ok) throw new Error(`保存模板失败 (${res.status})`);
|
||||
const template = (await res.json()) as CanvasTemplate;
|
||||
return { ...template, document: normalizeDocument(template.document) };
|
||||
}
|
||||
|
||||
export async function updateCanvasTemplate(
|
||||
id: string,
|
||||
partial: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
document?: CanvasDocument;
|
||||
referenceAssetIds?: string[];
|
||||
coverAssetId?: string;
|
||||
},
|
||||
): Promise<CanvasTemplate> {
|
||||
const fd = new FormData();
|
||||
if (partial.name !== undefined) fd.append('name', partial.name.trim() || '未命名模板');
|
||||
if (partial.description !== undefined) fd.append('description', partial.description.trim());
|
||||
if (partial.document) fd.append('document', JSON.stringify(normalizeDocument(partial.document)));
|
||||
if (partial.referenceAssetIds) fd.append('reference_asset_ids', JSON.stringify(partial.referenceAssetIds));
|
||||
if (partial.coverAssetId !== undefined) fd.append('cover_asset_id', partial.coverAssetId);
|
||||
const res = await fetch(`${API_BASE}/api/design-templates/${id}`, { method: 'PATCH', body: fd });
|
||||
if (!res.ok) throw new Error(`更新模板失败 (${res.status})`);
|
||||
const template = (await res.json()) as CanvasTemplate;
|
||||
return { ...template, document: normalizeDocument(template.document) };
|
||||
}
|
||||
|
||||
export async function deleteCanvasTemplate(id: string) {
|
||||
const res = await fetch(`${API_BASE}/api/design-templates/${id}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(`删除模板失败 (${res.status})`);
|
||||
}
|
||||
|
||||
export async function listAssets(type = ''): Promise<BackendAsset[]> {
|
||||
const query = type ? `?type=${encodeURIComponent(type)}` : '';
|
||||
const res = await fetch(`${API_BASE}/api/assets${query}`);
|
||||
if (!res.ok) throw new Error(`读取素材失败 (${res.status})`);
|
||||
return (await res.json()) as BackendAsset[];
|
||||
}
|
||||
|
||||
export async function uploadAsset(file: File, type = 'reference'): Promise<BackendAsset> {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
fd.append('name', file.name.replace(/\.(svg|png|jpe?g)$/i, ''));
|
||||
fd.append('type', type);
|
||||
const res = await fetch(`${API_BASE}/api/assets`, { method: 'POST', body: fd });
|
||||
if (!res.ok) throw new Error(`上传参考图失败 (${res.status})`);
|
||||
return (await res.json()) as BackendAsset;
|
||||
}
|
||||
|
||||
export function assetUrl(assetOrPath?: BackendAsset | string) {
|
||||
if (!assetOrPath) return '';
|
||||
const raw = typeof assetOrPath === 'string' ? assetOrPath : assetOrPath.file_url;
|
||||
if (!raw) return '';
|
||||
return raw.startsWith('http') ? raw : `${API_BASE}${raw}`;
|
||||
}
|
||||
|
||||
export function duplicateDocument(document: CanvasDocument): CanvasDocument {
|
||||
return cloneDocument(document);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
export interface ZipFileInput {
|
||||
name: string;
|
||||
content: string | Uint8Array;
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
export function createZip(files: ZipFileInput[]): Blob {
|
||||
const localParts: Uint8Array[] = [];
|
||||
const centralParts: Uint8Array[] = [];
|
||||
let offset = 0;
|
||||
|
||||
files.forEach(file => {
|
||||
const nameBytes = encoder.encode(file.name);
|
||||
const data = typeof file.content === 'string' ? encoder.encode(file.content) : file.content;
|
||||
const crc = crc32(data);
|
||||
const local = concatBytes([
|
||||
u32(0x04034b50),
|
||||
u16(20),
|
||||
u16(0),
|
||||
u16(0),
|
||||
u16(0),
|
||||
u16(0),
|
||||
u32(crc),
|
||||
u32(data.length),
|
||||
u32(data.length),
|
||||
u16(nameBytes.length),
|
||||
u16(0),
|
||||
nameBytes,
|
||||
data,
|
||||
]);
|
||||
localParts.push(local);
|
||||
|
||||
const central = concatBytes([
|
||||
u32(0x02014b50),
|
||||
u16(20),
|
||||
u16(20),
|
||||
u16(0),
|
||||
u16(0),
|
||||
u16(0),
|
||||
u16(0),
|
||||
u32(crc),
|
||||
u32(data.length),
|
||||
u32(data.length),
|
||||
u16(nameBytes.length),
|
||||
u16(0),
|
||||
u16(0),
|
||||
u16(0),
|
||||
u16(0),
|
||||
u32(0),
|
||||
u32(offset),
|
||||
nameBytes,
|
||||
]);
|
||||
centralParts.push(central);
|
||||
offset += local.length;
|
||||
});
|
||||
|
||||
const centralOffset = offset;
|
||||
const centralSize = centralParts.reduce((sum, part) => sum + part.length, 0);
|
||||
const end = concatBytes([
|
||||
u32(0x06054b50),
|
||||
u16(0),
|
||||
u16(0),
|
||||
u16(files.length),
|
||||
u16(files.length),
|
||||
u32(centralSize),
|
||||
u32(centralOffset),
|
||||
u16(0),
|
||||
]);
|
||||
|
||||
const bytes = concatBytes([...localParts, ...centralParts, end]);
|
||||
const buffer = new ArrayBuffer(bytes.length);
|
||||
new Uint8Array(buffer).set(bytes);
|
||||
return new Blob([buffer], { type: 'application/zip' });
|
||||
}
|
||||
|
||||
function u16(value: number) {
|
||||
const out = new Uint8Array(2);
|
||||
const view = new DataView(out.buffer);
|
||||
view.setUint16(0, value, true);
|
||||
return out;
|
||||
}
|
||||
|
||||
function u32(value: number) {
|
||||
const out = new Uint8Array(4);
|
||||
const view = new DataView(out.buffer);
|
||||
view.setUint32(0, value >>> 0, true);
|
||||
return out;
|
||||
}
|
||||
|
||||
function concatBytes(parts: Uint8Array[]) {
|
||||
const total = parts.reduce((sum, part) => sum + part.length, 0);
|
||||
const out = new Uint8Array(total);
|
||||
let cursor = 0;
|
||||
parts.forEach(part => {
|
||||
out.set(part, cursor);
|
||||
cursor += part.length;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
let crcTable: Uint32Array | null = null;
|
||||
|
||||
function crc32(data: Uint8Array) {
|
||||
const table = crcTable || buildCrcTable();
|
||||
let crc = 0xffffffff;
|
||||
for (let i = 0; i < data.length; i += 1) {
|
||||
crc = table[(crc ^ data[i]) & 0xff] ^ (crc >>> 8);
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function buildCrcTable() {
|
||||
const table = new Uint32Array(256);
|
||||
for (let i = 0; i < 256; i += 1) {
|
||||
let c = i;
|
||||
for (let k = 0; k < 8; k += 1) {
|
||||
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
}
|
||||
table[i] = c >>> 0;
|
||||
}
|
||||
crcTable = table;
|
||||
return table;
|
||||
}
|
||||
Reference in New Issue
Block a user