Add floating canvas panels, theme settings, and SVG line-spacing analysis.
Canvas Studio now uses dockable floating panels, app settings/help navigation, and improved SVG export; the backend adds an SVG line-spacing analysis API with SciPy acceleration and new design templates.
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
const rawApiBase = (import.meta.env.VITE_API_BASE ?? '').trim();
|
||||
|
||||
export const API_BASE = rawApiBase.replace(/\/+$/, '');
|
||||
|
||||
export function apiUrl(path: string) {
|
||||
if (!path) return API_BASE || '';
|
||||
if (/^https?:\/\//i.test(path) || path.startsWith('data:') || path.startsWith('blob:')) {
|
||||
return path;
|
||||
}
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||
return `${API_BASE}${normalizedPath}`;
|
||||
}
|
||||
|
||||
export function apiEventSource(path: string) {
|
||||
return new EventSource(apiUrl(path));
|
||||
}
|
||||
|
||||
export async function readApiError(res: Response) {
|
||||
const text = await res.text().catch(() => '');
|
||||
if (!text) return `${res.status} ${res.statusText}`.trim();
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
return parsed.detail || parsed.message || text;
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureOk(res: Response, fallback: string) {
|
||||
if (res.ok) return res;
|
||||
const detail = await readApiError(res);
|
||||
throw new Error(`${fallback} (${res.status})${detail ? `: ${detail}` : ''}`);
|
||||
}
|
||||
@@ -8,9 +8,10 @@ import {
|
||||
export const DPI = 96;
|
||||
export const MM_PER_INCH = 25.4;
|
||||
export const DEFAULT_LAYER_ID = 'layer-default';
|
||||
export const TRANSPARENT_BACKGROUND = 'transparent';
|
||||
|
||||
export function mmToPx(mm: number) {
|
||||
return Math.max(1, Math.round((mm / MM_PER_INCH) * DPI));
|
||||
return Math.max(1, (mm / MM_PER_INCH) * DPI);
|
||||
}
|
||||
|
||||
export function pxToMm(px: number) {
|
||||
@@ -21,6 +22,18 @@ export function formatMm(px: number) {
|
||||
return pxToMm(px).toFixed(1);
|
||||
}
|
||||
|
||||
export function hasCanvasBackground(background?: string | null) {
|
||||
const value = typeof background === 'string' ? background.trim().toLowerCase() : '';
|
||||
return value !== '' && value !== TRANSPARENT_BACKGROUND && value !== 'none' && value !== 'rgba(0,0,0,0)' && value !== 'rgba(0, 0, 0, 0)';
|
||||
}
|
||||
|
||||
export function normalizeCanvasBackground(background?: string | null) {
|
||||
if (typeof background !== 'string') return '#ffffff';
|
||||
const value = background.trim();
|
||||
if (!value) return '#ffffff';
|
||||
return hasCanvasBackground(value) ? value : TRANSPARENT_BACKGROUND;
|
||||
}
|
||||
|
||||
export function makeId(prefix: string) {
|
||||
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
|
||||
return crypto.randomUUID();
|
||||
@@ -95,7 +108,7 @@ export function normalizeDocument(input: CanvasDocument): CanvasDocument {
|
||||
return {
|
||||
width: Number.isFinite(input.width) ? input.width : 1600,
|
||||
height: Number.isFinite(input.height) ? input.height : 1000,
|
||||
background: input.background || '#ffffff',
|
||||
background: normalizeCanvasBackground(input.background),
|
||||
layers: nextLayers,
|
||||
layerFolders,
|
||||
elements,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { StickerAsset } from '../types';
|
||||
import { apiUrl, ensureOk } from './api';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backend-based sticker library
|
||||
@@ -76,8 +77,7 @@ interface BackendAsset {
|
||||
}
|
||||
|
||||
async function apiListAssets(): Promise<BackendAsset[]> {
|
||||
const res = await fetch('/api/assets?type=sticker');
|
||||
if (!res.ok) throw new Error(`list assets failed: ${res.status}`);
|
||||
const res = await ensureOk(await fetch(apiUrl('/api/assets?type=sticker')), '读取贴纸库失败');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -90,14 +90,15 @@ async function apiUploadAsset(
|
||||
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}`);
|
||||
const res = await ensureOk(await fetch(apiUrl('/api/assets'), { method: 'POST', body: form }), '上传贴纸失败');
|
||||
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}`);
|
||||
const res = await fetch(apiUrl(`/api/assets/${assetId}`), { method: 'DELETE' });
|
||||
if (!res.ok && res.status !== 404) {
|
||||
await ensureOk(res, '删除贴纸失败');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ─────────────────────────────────────────────────────────────
|
||||
@@ -152,5 +153,5 @@ export function svgToDataUrl(svg: string) {
|
||||
|
||||
export function assetToDataUrl(asset: StickerAsset) {
|
||||
// source is now a backend URL; return it directly
|
||||
return asset.source;
|
||||
return apiUrl(asset.source);
|
||||
}
|
||||
|
||||
+165
-17
@@ -1,5 +1,6 @@
|
||||
import { CanvasDocument, StickerAsset } from '../types';
|
||||
import { normalizeDocument, pxToMm } from './canvasDocument';
|
||||
import { apiUrl } from './api';
|
||||
import { hasCanvasBackground, normalizeDocument, pxToMm } from './canvasDocument';
|
||||
import { createZip } from './zip';
|
||||
|
||||
export interface SerializeOptions {
|
||||
@@ -8,7 +9,7 @@ export interface SerializeOptions {
|
||||
}
|
||||
|
||||
async function fetchBlobAsDataUrl(url: string): Promise<string> {
|
||||
const res = await fetch(url);
|
||||
const res = await fetch(apiUrl(url));
|
||||
if (!res.ok) throw new Error(`fetch failed: ${url} (${res.status})`);
|
||||
const blob = await res.blob();
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -19,16 +20,30 @@ async function fetchBlobAsDataUrl(url: string): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
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)}`;
|
||||
async function fetchText(url: string): Promise<string> {
|
||||
const res = await fetch(apiUrl(url));
|
||||
if (!res.ok) throw new Error(`fetch failed: ${url} (${res.status})`);
|
||||
return res.text();
|
||||
}
|
||||
|
||||
async function resolveStickerSvg(asset: StickerAsset): Promise<string | null> {
|
||||
if (asset.type !== 'svg') return null;
|
||||
const source = asset.source.trim();
|
||||
if (!source) return null;
|
||||
if (source.startsWith('<') || source.startsWith('<?xml')) {
|
||||
return source;
|
||||
}
|
||||
if (asset.source.startsWith('data:')) {
|
||||
return asset.source;
|
||||
if (source.startsWith('data:')) {
|
||||
return decodeDataUrl(source);
|
||||
}
|
||||
// Backend URL: fetch and inline so the exported SVG is self-contained
|
||||
return fetchBlobAsDataUrl(asset.source);
|
||||
return fetchText(source);
|
||||
}
|
||||
|
||||
async function resolveStickerImageHref(asset: StickerAsset): Promise<string | null> {
|
||||
const source = asset.source.trim();
|
||||
if (!source) return null;
|
||||
if (source.startsWith('data:')) return source;
|
||||
return fetchBlobAsDataUrl(source);
|
||||
}
|
||||
|
||||
export async function serializeDocument(
|
||||
@@ -45,7 +60,7 @@ export async function serializeDocument(
|
||||
`<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) {
|
||||
if (options.includeBackground !== false && hasCanvasBackground(doc.background)) {
|
||||
parts.push(`<rect width="100%" height="100%" fill="${escapeXml(doc.background)}"/>`);
|
||||
}
|
||||
|
||||
@@ -60,7 +75,14 @@ export async function serializeDocument(
|
||||
if (element.type === 'sticker') {
|
||||
const asset = stickerById.get(element.assetId);
|
||||
if (!asset) continue;
|
||||
const href = await resolveStickerHref(asset);
|
||||
const svg = await resolveStickerSvg(asset);
|
||||
if (svg) {
|
||||
const inline = serializeInlineSvgSticker(svg, element.width, element.height, transform, opacity, asset.tint === 'gray');
|
||||
if (inline) parts.push(inline);
|
||||
continue;
|
||||
}
|
||||
const href = await resolveStickerImageHref(asset);
|
||||
if (!href) continue;
|
||||
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;
|
||||
@@ -100,6 +122,13 @@ export async function createLayerExportZip(
|
||||
const files: { name: string; content: string }[] = [];
|
||||
const used = new Map<string, number>();
|
||||
|
||||
if (hasCanvasBackground(doc.background)) {
|
||||
files.push({
|
||||
name: uniqueSvgName('背景', used),
|
||||
content: serializeBackgroundLayer(doc),
|
||||
});
|
||||
}
|
||||
|
||||
for (const layerId of selectedLayerIds) {
|
||||
const layer = doc.layers?.find(item => item.id === layerId);
|
||||
if (!layer) continue;
|
||||
@@ -118,14 +147,133 @@ export async function createLayerExportZip(
|
||||
});
|
||||
}
|
||||
|
||||
files.push({
|
||||
name: uniqueSvgName('总效果', used),
|
||||
content: await serializeDocument(doc, stickerById, { includeBackground: true }),
|
||||
});
|
||||
|
||||
return createZip(files);
|
||||
}
|
||||
|
||||
function serializeBackgroundLayer(documentModel: CanvasDocument) {
|
||||
const doc = normalizeDocument(documentModel);
|
||||
const widthMm = pxToMm(doc.width).toFixed(1);
|
||||
const heightMm = pxToMm(doc.height).toFixed(1);
|
||||
return [
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${widthMm}mm" height="${heightMm}mm" viewBox="0 0 ${doc.width} ${doc.height}">`,
|
||||
`<rect width="100%" height="100%" fill="${escapeXml(doc.background)}"/>`,
|
||||
'</svg>',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function serializeInlineSvgSticker(
|
||||
svgText: string,
|
||||
targetWidth: number,
|
||||
targetHeight: number,
|
||||
transform: string,
|
||||
opacity: number,
|
||||
grayscale: boolean,
|
||||
) {
|
||||
const parsed = parseInlineSvg(svgText);
|
||||
if (!parsed || !parsed.content.trim()) return null;
|
||||
const scale = Math.min(targetWidth / parsed.width, targetHeight / parsed.height);
|
||||
const safeScale = Number.isFinite(scale) && scale > 0 ? scale : 1;
|
||||
const offsetX = (targetWidth - parsed.width * safeScale) / 2;
|
||||
const offsetY = (targetHeight - parsed.height * safeScale) / 2;
|
||||
const contentTransform = [
|
||||
transform,
|
||||
`translate(${formatSvgNumber(offsetX)} ${formatSvgNumber(offsetY)})`,
|
||||
`scale(${formatSvgNumber(safeScale)})`,
|
||||
`translate(${formatSvgNumber(-parsed.minX)} ${formatSvgNumber(-parsed.minY)})`,
|
||||
].join(' ');
|
||||
const filter = grayscale ? ' style="filter: grayscale(1)"' : '';
|
||||
return `<g opacity="${formatSvgNumber(opacity)}" transform="${contentTransform}"${filter}>\n${parsed.content}\n</g>`;
|
||||
}
|
||||
|
||||
function parseInlineSvg(svgText: string) {
|
||||
const parser = new DOMParser();
|
||||
const xml = parser.parseFromString(svgText, 'image/svg+xml');
|
||||
if (xml.querySelector('parsererror')) return null;
|
||||
const root = xml.documentElement;
|
||||
if (!root || root.localName.toLowerCase() !== 'svg') return null;
|
||||
removeEmptyReferences(root);
|
||||
|
||||
const viewBox = parseViewBox(root.getAttribute('viewBox') || root.getAttribute('viewbox'));
|
||||
const sourceWidth = viewBox?.width || parseSvgLength(root.getAttribute('width'));
|
||||
const sourceHeight = viewBox?.height || parseSvgLength(root.getAttribute('height'));
|
||||
const width = sourceWidth && sourceWidth > 0 ? sourceWidth : viewBox?.width || 1;
|
||||
const height = sourceHeight && sourceHeight > 0 ? sourceHeight : viewBox?.height || 1;
|
||||
const minX = viewBox?.minX || 0;
|
||||
const minY = viewBox?.minY || 0;
|
||||
const serializer = new XMLSerializer();
|
||||
const body = Array.from(root.childNodes)
|
||||
.map(node => serializer.serializeToString(node))
|
||||
.join('\n');
|
||||
const rootAttributes = serializeRootPresentationAttributes(root);
|
||||
const content = rootAttributes ? `<g ${rootAttributes}>\n${body}\n</g>` : body;
|
||||
return { content, minX, minY, width, height };
|
||||
}
|
||||
|
||||
function removeEmptyReferences(root: Element) {
|
||||
Array.from(root.querySelectorAll('*')).forEach(element => {
|
||||
const hrefAttributes = Array.from(element.attributes).filter(attr => attr.localName === 'href' || attr.name === 'href' || attr.name.endsWith(':href'));
|
||||
hrefAttributes.forEach(attr => {
|
||||
if (!attr.value.trim()) element.removeAttribute(attr.name);
|
||||
});
|
||||
if (element.localName.toLowerCase() === 'image') {
|
||||
const href = element.getAttribute('href') || element.getAttribute('xlink:href') || element.getAttributeNS('http://www.w3.org/1999/xlink', 'href');
|
||||
if (!href || !href.trim()) element.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function serializeRootPresentationAttributes(root: Element) {
|
||||
const excluded = new Set([
|
||||
'height',
|
||||
'id',
|
||||
'preserveAspectRatio',
|
||||
'version',
|
||||
'viewBox',
|
||||
'viewbox',
|
||||
'width',
|
||||
'x',
|
||||
'y',
|
||||
]);
|
||||
return Array.from(root.attributes)
|
||||
.filter(attr => !excluded.has(attr.name) && !attr.name.startsWith('xmlns'))
|
||||
.map(attr => `${attr.name}="${escapeXml(attr.value)}"`)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function parseViewBox(value: string | null) {
|
||||
if (!value) return null;
|
||||
const parts = value.trim().split(/[\s,]+/).map(Number);
|
||||
if (parts.length !== 4 || parts.some(part => !Number.isFinite(part))) return null;
|
||||
const [minX, minY, width, height] = parts;
|
||||
if (width <= 0 || height <= 0) return null;
|
||||
return { minX, minY, width, height };
|
||||
}
|
||||
|
||||
function parseSvgLength(value: string | null) {
|
||||
if (!value) return null;
|
||||
const match = value.trim().match(/^(-?\d+(?:\.\d+)?)/);
|
||||
if (!match) return null;
|
||||
const parsed = Number.parseFloat(match[1]);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
function decodeDataUrl(dataUrl: string) {
|
||||
const commaIndex = dataUrl.indexOf(',');
|
||||
if (commaIndex < 0) return null;
|
||||
const header = dataUrl.slice(0, commaIndex);
|
||||
const payload = dataUrl.slice(commaIndex + 1);
|
||||
if (!/image\/svg\+xml/i.test(header)) return null;
|
||||
try {
|
||||
return /;base64/i.test(header) ? atob(payload) : decodeURIComponent(payload);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatSvgNumber(value: number) {
|
||||
return Number.isFinite(value) ? Number.parseFloat(value.toFixed(4)).toString() : '0';
|
||||
}
|
||||
|
||||
function uniqueSvgName(name: string, used: Map<string, number>) {
|
||||
const base = sanitizeFileName(name || '未命名');
|
||||
const count = used.get(base) || 0;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { BackendAsset, CanvasDocument, CanvasTemplate } from '../types';
|
||||
import { apiUrl, ensureOk } from './api';
|
||||
import { cloneDocument, normalizeDocument } from './canvasDocument';
|
||||
|
||||
const API_BASE = '';
|
||||
|
||||
export function templateId(template: CanvasTemplate) {
|
||||
return template.template_id || template.id || '';
|
||||
}
|
||||
@@ -24,8 +23,7 @@ export function templateCoverId(template: CanvasTemplate) {
|
||||
}
|
||||
|
||||
export async function listDesignTemplates(): Promise<CanvasTemplate[]> {
|
||||
const res = await fetch(`${API_BASE}/api/design-templates`);
|
||||
if (!res.ok) throw new Error(`读取模板库失败 (${res.status})`);
|
||||
const res = await ensureOk(await fetch(apiUrl('/api/design-templates')), '读取模板库失败');
|
||||
const items = (await res.json()) as CanvasTemplate[];
|
||||
return items.map(item => ({ ...item, document: normalizeDocument(item.document) }));
|
||||
}
|
||||
@@ -43,8 +41,7 @@ export async function createCanvasTemplate(input: {
|
||||
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 res = await ensureOk(await fetch(apiUrl('/api/design-templates'), { method: 'POST', body: fd }), '保存模板失败');
|
||||
const template = (await res.json()) as CanvasTemplate;
|
||||
return { ...template, document: normalizeDocument(template.document) };
|
||||
}
|
||||
@@ -65,21 +62,18 @@ export async function updateCanvasTemplate(
|
||||
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 res = await ensureOk(await fetch(apiUrl(`/api/design-templates/${id}`), { method: 'PATCH', body: fd }), '更新模板失败');
|
||||
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})`);
|
||||
await ensureOk(await fetch(apiUrl(`/api/design-templates/${id}`), { method: 'DELETE' }), '删除模板失败');
|
||||
}
|
||||
|
||||
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})`);
|
||||
const res = await ensureOk(await fetch(apiUrl(`/api/assets${query}`)), '读取素材失败');
|
||||
return (await res.json()) as BackendAsset[];
|
||||
}
|
||||
|
||||
@@ -88,8 +82,7 @@ export async function uploadAsset(file: File, type = 'reference'): Promise<Backe
|
||||
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})`);
|
||||
const res = await ensureOk(await fetch(apiUrl('/api/assets'), { method: 'POST', body: fd }), '上传参考图失败');
|
||||
return (await res.json()) as BackendAsset;
|
||||
}
|
||||
|
||||
@@ -97,7 +90,7 @@ 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}`;
|
||||
return apiUrl(raw);
|
||||
}
|
||||
|
||||
export function duplicateDocument(document: CanvasDocument): CanvasDocument {
|
||||
|
||||
Reference in New Issue
Block a user