Initial project baseline

This commit is contained in:
2026-07-04 02:40:45 +08:00
commit d5d8caef2f
86 changed files with 15590 additions and 0 deletions
+138
View File
@@ -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);
}
+156
View File
@@ -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;
}
+147
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
+105
View File
@@ -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);
}
+124
View File
@@ -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;
}